-
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"plugin-data": {
|
||||
"description": "Angiv et søgefelt til dine brugere, så de kan gennemsøge indholdet på dit websted.",
|
||||
"name": "Search"
|
||||
},
|
||||
"search": "Søg",
|
||||
"show-button-search": "Vis knappen søg",
|
||||
"search-results-for": "Søgeresultater for",
|
||||
"result-found": "Resultat fundet",
|
||||
"results-found": "Resultater fundet",
|
||||
"no-results-found": "Ingen resultater fundet",
|
||||
"no-pages-found-for-the-search": "Vi kunne ikke finde nogen sider, der matcher \"%s\"",
|
||||
"suggestions": "Forslag",
|
||||
"check-your-spelling": "Tjek din stavning",
|
||||
"try-different-keywords": "Prøv andre søgeord",
|
||||
"try-more-general-keywords": "Prøv mere generelle søgeord",
|
||||
"minimum-number-of-characters-when-searching": "Mindste antal tegn ved søgning",
|
||||
"highlight-search-terms-in-results": "Fremhæv søgetermer i resultaterne",
|
||||
"show-result-count": "Vis antal resultater",
|
||||
"search-in-tags": "Søg i tags",
|
||||
"search-in-categories": "Søg i kategorier",
|
||||
"excerpt-length-(characters)": "Uddragslængde (tegn)"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"plugin-data": {
|
||||
"name": "Suche",
|
||||
"description": "Fügt ein Suchfeld ein, mit dem die Inhalte der Website durchsucht werden können."
|
||||
},
|
||||
"search": "Suchen",
|
||||
"show-button-search": "Den Button \"Suchen\" anzeigen",
|
||||
"search-results-for": "Suchergebnisse für",
|
||||
"result-found": "Ergebnis gefunden",
|
||||
"results-found": "Ergebnisse gefunden",
|
||||
"no-results-found": "Keine Ergebnisse gefunden",
|
||||
"no-pages-found-for-the-search": "Wir konnten keine Seiten finden, die \"%s\" entsprechen",
|
||||
"suggestions": "Vorschläge",
|
||||
"check-your-spelling": "Überprüfen Sie Ihre Rechtschreibung",
|
||||
"try-different-keywords": "Versuchen Sie es mit anderen Schlüsselwörtern",
|
||||
"try-more-general-keywords": "Versuchen Sie es mit allgemeineren Schlüsselwörtern",
|
||||
"minimum-number-of-characters-when-searching": "Mindestanzahl der Zeichen bei der Suche",
|
||||
"highlight-search-terms-in-results": "Suchbegriffe in den Ergebnissen hervorheben",
|
||||
"show-result-count": "Ergebnisanzahl anzeigen",
|
||||
"search-in-tags": "In Tags suchen",
|
||||
"search-in-categories": "In Kategorien suchen",
|
||||
"excerpt-length-(characters)": "Auszugslänge (Zeichen)"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"plugin-data": {
|
||||
"name": "Suche",
|
||||
"description": "Fügt ein Suchfeld ein, mit dem die Inhalte der Website durchsucht werden können."
|
||||
},
|
||||
"search": "Suchen",
|
||||
"show-button-search": "Den Button \"Suchen\" anzeigen",
|
||||
"search-results-for": "Suchergebnisse für",
|
||||
"result-found": "Ergebnis gefunden",
|
||||
"results-found": "Ergebnisse gefunden",
|
||||
"no-results-found": "Keine Ergebnisse gefunden",
|
||||
"no-pages-found-for-the-search": "Wir konnten keine Seiten finden, die \"%s\" entsprechen",
|
||||
"suggestions": "Vorschläge",
|
||||
"check-your-spelling": "Überprüfen Sie Ihre Rechtschreibung",
|
||||
"try-different-keywords": "Versuchen Sie es mit anderen Schlüsselwörtern",
|
||||
"try-more-general-keywords": "Versuchen Sie es mit allgemeineren Schlüsselwörtern",
|
||||
"minimum-number-of-characters-when-searching": "Mindestanzahl der Zeichen bei der Suche",
|
||||
"highlight-search-terms-in-results": "Suchbegriffe in den Ergebnissen hervorheben",
|
||||
"show-result-count": "Ergebnisanzahl anzeigen",
|
||||
"search-in-tags": "In Tags suchen",
|
||||
"search-in-categories": "In Kategorien suchen",
|
||||
"excerpt-length-(characters)": "Auszugslänge (Zeichen)"
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"plugin-data":
|
||||
{
|
||||
"name": "Search",
|
||||
"description": "Provide a search box to your users to search through the content of your site."
|
||||
},
|
||||
"search": "Search",
|
||||
"show-button-search": "Show button search",
|
||||
"search-results-for": "Search results for",
|
||||
"result-found": "Result found",
|
||||
"results-found": "Results found",
|
||||
"no-results-found": "No results found",
|
||||
"no-pages-found-for-the-search": "We couldn't find any pages matching \"%s\"",
|
||||
"suggestions": "Suggestions",
|
||||
"check-your-spelling": "Check your spelling",
|
||||
"try-different-keywords": "Try different keywords",
|
||||
"try-more-general-keywords": "Try more general keywords",
|
||||
"minimum-number-of-characters-when-searching": "Minimum number of characters when searching",
|
||||
"words-to-cache-per-page": "Words to cache per page",
|
||||
"highlight-search-terms-in-results": "Highlight search terms in results",
|
||||
"show-result-count": "Show result count",
|
||||
"search-in-tags": "Search in tags",
|
||||
"search-in-categories": "Search in categories",
|
||||
"excerpt-length-(characters)": "Excerpt length (characters)"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"plugin-data": {
|
||||
"name": "Buscador",
|
||||
"description": "Este plugin provee un buscador para tus usuarios para buscar a través del contenido de su sitio."
|
||||
},
|
||||
"search": "Buscar",
|
||||
"show-button-search": "Mostrar boton buscar",
|
||||
"search-results-for": "Resultados de búsqueda para",
|
||||
"result-found": "Resultado encontrado",
|
||||
"results-found": "Resultados encontrados",
|
||||
"no-results-found": "No se encontraron resultados",
|
||||
"no-pages-found-for-the-search": "No pudimos encontrar páginas que coincidan con \"%s\"",
|
||||
"suggestions": "Sugerencias",
|
||||
"check-your-spelling": "Verifica tu ortografía",
|
||||
"try-different-keywords": "Intenta con palabras clave diferentes",
|
||||
"try-more-general-keywords": "Intenta con palabras clave más generales",
|
||||
"minimum-number-of-characters-when-searching": "Número mínimo de caracteres al buscar",
|
||||
"highlight-search-terms-in-results": "Resaltar términos de búsqueda en los resultados",
|
||||
"show-result-count": "Mostrar recuento de resultados",
|
||||
"search-in-tags": "Buscar en etiquetas",
|
||||
"search-in-categories": "Buscar en categorías",
|
||||
"excerpt-length-(characters)": "Longitud del extracto (caracteres)"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"plugin-data": {
|
||||
"name": "جستجو",
|
||||
"description": "برای جستجو از طریق محتوای سایت خود، کادر جستجو را برای کاربران خود فراهم کنید."
|
||||
},
|
||||
"search": "جستجو",
|
||||
"show-button-search": "نمایش دکمه جستجو",
|
||||
"search-results-for": "نتایج جستجو برای",
|
||||
"result-found": "نتیجه پیدا شد",
|
||||
"results-found": "نتیجه پیدا شد",
|
||||
"no-results-found": "نتیجهای یافت نشد",
|
||||
"no-pages-found-for-the-search": "ما هیچ صفحهای مطابق با \"%s\" پیدا نکردیم",
|
||||
"suggestions": "پیشنهادات",
|
||||
"check-your-spelling": "املای خود را بررسی کنید",
|
||||
"try-different-keywords": "کلمات کلیدی مختلف را امتحان کنید",
|
||||
"try-more-general-keywords": "کلمات کلیدی عمومیتری را امتحان کنید",
|
||||
"minimum-number-of-characters-when-searching": "حداقل تعداد کاراکترها هنگام جستجو",
|
||||
"highlight-search-terms-in-results": "برجسته کردن عبارات جستجو در نتایج",
|
||||
"show-result-count": "نمایش تعداد نتایج",
|
||||
"search-in-tags": "جستجو در برچسبها",
|
||||
"search-in-categories": "جستجو در دستهها",
|
||||
"excerpt-length-(characters)": "طول چکیده (کاراکتر)"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"plugin-data":
|
||||
{
|
||||
"name": "Recherche",
|
||||
"description": "Fournit un champ de recherche à vos utilisateurs pour qu'ils puissent effectuer des recherches dans le contenu de votre site."
|
||||
},
|
||||
"search": "Chercher",
|
||||
"show-button-search": "Montrer le bouton de recherche",
|
||||
"search-results-for": "Résultats pour",
|
||||
"result-found": "Résultat trouvé",
|
||||
"results-found": "Résultats trouvés",
|
||||
"no-results-found": "Aucun résultat trouvé",
|
||||
"no-pages-found-for-the-search": "Aucune page trouvée pour la recherche \"%s\"",
|
||||
"suggestions": "Suggestions",
|
||||
"check-your-spelling": "Vérifier l'orthographe",
|
||||
"try-different-keywords": "Essayer d'autres mots-clés",
|
||||
"try-more-general-keywords": "Essayer des mots-clés plus généraux",
|
||||
"minimum-number-of-characters-when-searching": "Nombre minimum de caractères pour la recherche",
|
||||
"highlight-search-terms-in-results": "Mettre en surbrillance les termes recherchés dans les résultats",
|
||||
"show-result-count": "Montrer le nombre de résultats",
|
||||
"search-in-tags": "Rechercher dans les tags",
|
||||
"search-in-categories": "Rechercher dans les catégories",
|
||||
"excerpt-length-(characters)": "Longueur de l'extrait (caractères)"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"plugin-data": {
|
||||
"name": "Cerca",
|
||||
"description": "Offre un campo di ricerca ai tuoi utenti per poter cercare tra il contenuto del tuo sito."
|
||||
},
|
||||
"search": "Cerca",
|
||||
"show-button-search": "Mostra pulsante cerca",
|
||||
"search-results-for": "Risultati della ricerca per",
|
||||
"result-found": "Risultato trovato",
|
||||
"results-found": "Risultati trovati",
|
||||
"no-results-found": "Nessun risultato trovato",
|
||||
"no-pages-found-for-the-search": "Non abbiamo trovato pagine corrispondenti a \"%s\"",
|
||||
"suggestions": "Suggerimenti",
|
||||
"check-your-spelling": "Controlla l'ortografia",
|
||||
"try-different-keywords": "Prova parole chiave diverse",
|
||||
"try-more-general-keywords": "Prova parole chiave più generiche",
|
||||
"minimum-number-of-characters-when-searching": "Numero minimo di caratteri durante la ricerca",
|
||||
"highlight-search-terms-in-results": "Evidenzia i termini di ricerca nei risultati",
|
||||
"show-result-count": "Mostra il numero dei risultati",
|
||||
"search-in-tags": "Cerca nei tag",
|
||||
"search-in-categories": "Cerca nelle categorie",
|
||||
"excerpt-length-(characters)": "Lunghezza dell'estratto (caratteri)"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"plugin-data": {
|
||||
"name": "検索",
|
||||
"description": "Provide a search box to your users to search through the content of your site."
|
||||
},
|
||||
"search": "検索",
|
||||
"show-button-search": "検索ボタンを表示",
|
||||
"search-results-for": "の検索結果",
|
||||
"result-found": "件の結果が見つかりました",
|
||||
"results-found": "件の結果が見つかりました",
|
||||
"no-results-found": "結果が見つかりませんでした",
|
||||
"no-pages-found-for-the-search": "「%s」に一致するページは見つかりませんでした",
|
||||
"suggestions": "提案",
|
||||
"check-your-spelling": "スペルを確認してください",
|
||||
"try-different-keywords": "別のキーワードを試してください",
|
||||
"try-more-general-keywords": "より一般的なキーワードを試してください",
|
||||
"minimum-number-of-characters-when-searching": "検索時の最小文字数",
|
||||
"highlight-search-terms-in-results": "検索結果の検索語をハイライトする",
|
||||
"show-result-count": "検索結果数を表示する",
|
||||
"search-in-tags": "タグ内を検索する",
|
||||
"search-in-categories": "カテゴリー内を検索する",
|
||||
"excerpt-length-(characters)": "抜粋の長さ(文字数)"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"plugin-data": {
|
||||
"name": "Zoeken",
|
||||
"description": "Toont een zoekveld om alle inhoud van de website te doorzoeken."
|
||||
},
|
||||
"search": "Zoeken",
|
||||
"show-button-search": "Knop Zoeken tonen",
|
||||
"search-results-for": "Zoekresultaten voor",
|
||||
"result-found": "Resultaat gevonden",
|
||||
"results-found": "Resultaten gevonden",
|
||||
"no-results-found": "Geen resultaten gevonden",
|
||||
"no-pages-found-for-the-search": "Er zijn geen pagina's gevonden die \"%s\" bevatten",
|
||||
"suggestions": "Suggesties",
|
||||
"check-your-spelling": "Controleer de spelling",
|
||||
"try-different-keywords": "Probeer andere trefwoorden",
|
||||
"try-more-general-keywords": "Probeer algemenere trefwoorden",
|
||||
"minimum-number-of-characters-when-searching": "Minimum aantal tekens bij het zoeken",
|
||||
"highlight-search-terms-in-results": "Zoektermen markeren in resultaten",
|
||||
"show-result-count": "Aantal resultaten weergeven",
|
||||
"search-in-tags": "Zoeken in tags",
|
||||
"search-in-categories": "Zoeken in categorieën",
|
||||
"excerpt-length-(characters)": "Lengte citaat (tekens)"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"plugin-data": {
|
||||
"name": "Поиск",
|
||||
"description": "Добавляет поисковое поле по содержимому вашего сайта для пользователей."
|
||||
},
|
||||
"search": "Поиск",
|
||||
"show-button-search": "Show button search",
|
||||
"search-results-for": "Результаты поиска для",
|
||||
"result-found": "Результат найден",
|
||||
"results-found": "Результатов найдено",
|
||||
"no-results-found": "Результатов не найдено",
|
||||
"no-pages-found-for-the-search": "Мы не смогли найти страницы, соответствующие «%s»",
|
||||
"suggestions": "Предложения",
|
||||
"check-your-spelling": "Проверьте правописание",
|
||||
"try-different-keywords": "Попробуйте другие ключевые слова",
|
||||
"try-more-general-keywords": "Попробуйте более общие ключевые слова",
|
||||
"minimum-number-of-characters-when-searching": "Минимальное количество символов при поиске",
|
||||
"highlight-search-terms-in-results": "Выделять поисковые запросы в результатах",
|
||||
"show-result-count": "Показывать количество результатов",
|
||||
"search-in-tags": "Поиск в тегах",
|
||||
"search-in-categories": "Поиск в категориях",
|
||||
"excerpt-length-(characters)": "Длина отрывка (символов)"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"plugin-data": {
|
||||
"name": "Ara",
|
||||
"description": "Sitenizin içeriğinde arama yapmak için kullanıcılarınıza bir arama kutusu sağlayın."
|
||||
},
|
||||
"search": "Ara",
|
||||
"show-button-search": "Arama düğmesini göster",
|
||||
"search-results-for": "Arama sonuçları:",
|
||||
"result-found": "Sonuç bulundu",
|
||||
"results-found": "Sonuç bulundu",
|
||||
"no-results-found": "Sonuç bulunamadı",
|
||||
"no-pages-found-for-the-search": "\"%s\" ile eşleşen sayfa bulunamadı",
|
||||
"suggestions": "Öneriler",
|
||||
"check-your-spelling": "Yazımınızı kontrol edin",
|
||||
"try-different-keywords": "Farklı anahtar kelimeler deneyin",
|
||||
"try-more-general-keywords": "Daha genel anahtar kelimeler deneyin",
|
||||
"minimum-number-of-characters-when-searching": "Arama yaparken minimum karakter sayısı",
|
||||
"highlight-search-terms-in-results": "Sonuçlarda arama terimlerini vurgula",
|
||||
"show-result-count": "Sonuç sayısını göster",
|
||||
"search-in-tags": "Etiketlerde ara",
|
||||
"search-in-categories": "Kategorilerde ara",
|
||||
"excerpt-length-(characters)": "Alıntı uzunluğu (karakter)"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"author": "Bludit",
|
||||
"email": "",
|
||||
"website": "https://plugins.bludit.com",
|
||||
"version": "3.22.0",
|
||||
"releaseDate": "2026-05-10",
|
||||
"license": "MIT",
|
||||
"compatible": "3.22",
|
||||
"notes": ""
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+378
@@ -0,0 +1,378 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* This script was modified to fit in Bludit
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fuzz Class
|
||||
* kevinfiol\fuzzget
|
||||
*
|
||||
* @category Class
|
||||
* @package None
|
||||
* @author Kevin Fiol <fiolkevin@gmail.com>
|
||||
* @license https://opensource.org/licenses/MIT MIT License
|
||||
* @link http://github.com/kevinfiol
|
||||
*/
|
||||
|
||||
class Fuzz
|
||||
{
|
||||
private $_source;
|
||||
private $_sourceLen;
|
||||
private $_maxResults;
|
||||
private $_searchMode;
|
||||
private $_useLCS;
|
||||
|
||||
/**
|
||||
* Fuzz Object Constructor
|
||||
* Initialize private variables
|
||||
*
|
||||
* @param array $source An array of associative arrays
|
||||
* @param int $maxResults The maximum number of results to retrieve upon a search
|
||||
* @param int $searchMode 0 = Levenshtein, 1 = Jaro-Winkler
|
||||
* @param boolean $useLCS Factor in Longest Common Substring in search results
|
||||
*/
|
||||
public function __construct($source, $maxResults, $searchMode, $useLCS)
|
||||
{
|
||||
$this->_source = $source;
|
||||
$this->_sourceLen = count($source);
|
||||
$this->_maxResults = max($maxResults, 1);
|
||||
$this->_useLCS = $useLCS;
|
||||
|
||||
if ($searchMode < 0 || $searchMode > 1) {
|
||||
throw new \Exception('Invalid search mode');
|
||||
} else {
|
||||
$this->_searchMode = $searchMode;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search Method
|
||||
* Initiate Search
|
||||
*
|
||||
* @param string $search Term to search for
|
||||
* @param int $minLCS (if using LCS) Specify the minimum longest common substring
|
||||
* @param int $maxDistance (if using Levenshtein) Specify the maximum distance allowed
|
||||
*
|
||||
* @return array $results Array of associative arrays containing search matches
|
||||
*/
|
||||
public function search($search, $minLCS = null, $maxDistance = null)
|
||||
{
|
||||
$results = [];
|
||||
$scores = [];
|
||||
|
||||
// Nullify these parameters if they are irrelevant to searchMode
|
||||
if (!$this->_useLCS) $minLCS = null;
|
||||
if ($this->_searchMode != 0) $maxDistance = null;
|
||||
|
||||
// Cycle through result pool
|
||||
//for ($i = 0; $i < $this->_sourceLen; $i++) {
|
||||
foreach ($this->_source as $pageKey => $data) {
|
||||
$allLev = [];
|
||||
$allJaros = [];
|
||||
$allLCSs = [];
|
||||
|
||||
// Cycle through each object's properties
|
||||
foreach ($data as $key => $val) {
|
||||
if ($this->_searchMode == 0) {
|
||||
$allLev[] = $this->getLevenshtein(strval($val), $search);
|
||||
} elseif ($this->_searchMode == 1) {
|
||||
$allJaros[] = $this->getJaroWinkler(strval($val), $search);
|
||||
}
|
||||
|
||||
if ($this->_useLCS) {
|
||||
$allLCSs[] = $this->getLCS(strval($val), $search);
|
||||
}
|
||||
}
|
||||
|
||||
$lowestLev = $allLev ? min($allLev) : null;
|
||||
$highestJaro = $allJaros ? max($allJaros) : null;
|
||||
$highestLCS = $allLCSs ? max($allLCSs) : null;
|
||||
|
||||
// Get Score
|
||||
if ($this->_searchMode == 0) {
|
||||
$score = $lowestLev;
|
||||
} else {
|
||||
$score = -1 * abs($highestJaro);
|
||||
}
|
||||
|
||||
if ($this->_useLCS) {
|
||||
$score -= $highestLCS;
|
||||
}
|
||||
|
||||
// Append Index of object + Best Score
|
||||
if (($maxDistance == null || $lowestLev <= $maxDistance)
|
||||
&& ($minLCS == null || $highestLCS >= $minLCS)
|
||||
) {
|
||||
$scores[$pageKey] = $score;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score
|
||||
asort($scores);
|
||||
return $scores;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Longest Common Substring
|
||||
*
|
||||
* @param string $source Term to search for
|
||||
* @param string $target Target term to search against
|
||||
*
|
||||
* @return int $result LCS Score
|
||||
*/
|
||||
public function getLCS($source, $target)
|
||||
{
|
||||
$suffix = [];
|
||||
$result = 0;
|
||||
$n = mb_strlen($source, CHARSET);
|
||||
$m = mb_strlen($target, CHARSET);
|
||||
|
||||
for ($i = 0; $i <= $n; $i++) {
|
||||
for ($j = 0; $j <= $m; $j++) {
|
||||
if ($i === 0 || $j === 0) {
|
||||
$suffix[$i][$j] = 0;
|
||||
} elseif ($source[$i - 1] == $target[$j - 1]) {
|
||||
$suffix[$i][$j] = $suffix[$i - 1][$j - 1] + 1;
|
||||
$result = max($result, $suffix[$i][$j]);
|
||||
} else {
|
||||
$suffix[$i][$j] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Levenshtein Distance
|
||||
*
|
||||
* @param string $source Term to search for
|
||||
* @param string $target Target term to search against
|
||||
*
|
||||
* @return int Levenshtein Distance
|
||||
*/
|
||||
public function getLevenshtein($source, $target)
|
||||
{
|
||||
$matrix = [];
|
||||
$n = mb_strlen($source, CHARSET);
|
||||
$m = mb_strlen($target, CHARSET);
|
||||
|
||||
if ($n === 0) {
|
||||
return $m;
|
||||
} elseif ($m === 0) {
|
||||
return $n;
|
||||
}
|
||||
|
||||
// Initialize First Row
|
||||
for ($i = 0; $i <= $n; $i++) {
|
||||
$matrix[0][$i] = $i;
|
||||
}
|
||||
// Initialize First Column
|
||||
for ($i = 0; $i <= $m; $i++) {
|
||||
$matrix[$i][0] = $i;
|
||||
}
|
||||
|
||||
for ($i = 1; $i <= $n; $i++) {
|
||||
for ($j = 1; $j <= $m; $j++) {
|
||||
if ($source[$i - 1] === $target[$j - 1]) {
|
||||
$cost = 0;
|
||||
} else {
|
||||
$cost = 1;
|
||||
}
|
||||
|
||||
// Cell immediately above + 1
|
||||
$up = $matrix[$j - 1][$i] + 1;
|
||||
// Cell immediately to the left + 1
|
||||
$left = $matrix[$j][$i - 1] + 1;
|
||||
// Cell diagnolly above and to the left + cost
|
||||
$upleft = $matrix[$j - 1][$i - 1] + $cost;
|
||||
|
||||
$matrix[$j][$i] = min($up, $left, $upleft);
|
||||
}
|
||||
}
|
||||
|
||||
return $matrix[$m][$n];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Jaro-Winkler Score
|
||||
*
|
||||
* @param string $first String to match
|
||||
* @param string $second String to match
|
||||
*
|
||||
* @return double $jaroWinkler Jaro-Winkler score between 0.0 and 1.0
|
||||
*/
|
||||
public function getJaroWinkler($first, $second)
|
||||
{
|
||||
$shorter;
|
||||
$longer;
|
||||
|
||||
if (mb_strlen($first, CHARSET) > mb_strlen($second, CHARSET)) {
|
||||
$longer = mb_strtolower($first, CHARSET);
|
||||
$shorter = mb_strtolower($second, CHARSET);
|
||||
} else {
|
||||
$longer = mb_strtolower($second, CHARSET);
|
||||
$shorter = mb_strtolower($first, CHARSET);
|
||||
}
|
||||
|
||||
// Get half the length distance of shorter string
|
||||
$halfLen = intval((mb_strlen($shorter,CHARSET) / 2) + 1);
|
||||
|
||||
$match1 = $this->_getCharMatch($shorter, $longer, $halfLen);
|
||||
$match2 = $this->_getCharMatch($longer, $shorter, $halfLen);
|
||||
|
||||
if ((mb_strlen($match1, CHARSET) == 0 || mb_strlen($match2, CHARSET) == 0)
|
||||
|| (mb_strlen($match1, CHARSET) != mb_strlen($match2, CHARSET))
|
||||
) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$trans = $this->_getTranspositions($match1, $match2);
|
||||
|
||||
$distance = (mb_strlen($match1, CHARSET) / mb_strlen($shorter, CHARSET)
|
||||
+ mb_strlen($match2, CHARSET) / mb_strlen($longer, CHARSET)
|
||||
+ (mb_strlen($match1, CHARSET) - $trans)
|
||||
/ mb_strlen($match1, CHARSET)) / 3.0;
|
||||
|
||||
// Apply Winkler Adjustment
|
||||
$prefixLen = min(mb_strlen($this->_getPrefix($first, $second),CHARSET), 4);
|
||||
$jaroWinkler = round(($distance + (0.1 * $prefixLen * (1.0 - $distance))) * 100.0) / 100.0;
|
||||
|
||||
return $jaroWinkler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Character Matches
|
||||
*
|
||||
* @param string $first String to match
|
||||
* @param string $second String to match
|
||||
* @param int $limit Limit of characters to match
|
||||
*
|
||||
* @return string $common Common substring
|
||||
*/
|
||||
private function _getCharMatch($first, $second, $limit)
|
||||
{
|
||||
$common = '';
|
||||
$copy = $second;
|
||||
$firstLen = mb_strlen($first, CHARSET);
|
||||
$secondLen = mb_strlen($second, CHARSET);
|
||||
|
||||
for ($i = 0; $i < $firstLen; $i++) {
|
||||
$char = $first[$i];
|
||||
$found = false;
|
||||
|
||||
for ($j = max(0, $i - $limit); !$found && $j < min($i + $limit, $secondLen); $j++) {
|
||||
if ($copy[$j] == $char) {
|
||||
$found = true;
|
||||
$common .= $char;
|
||||
$copy[$j] = '*';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $common;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Transpositions
|
||||
*
|
||||
* @param string $first String to match
|
||||
* @param string $second String to match
|
||||
*
|
||||
* @return int $trans Number of transpositions between strings
|
||||
*/
|
||||
private function _getTranspositions($first, $second)
|
||||
{
|
||||
$trans = 0;
|
||||
$firstLen = mb_strlen($first, CHARSET);
|
||||
|
||||
for ($i = 0; $i < $firstLen; $i++) {
|
||||
if ($first[$i] != $second[$i]) {
|
||||
$trans += 1;
|
||||
}
|
||||
}
|
||||
|
||||
$trans /= 2;
|
||||
return $trans;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Prefix
|
||||
*
|
||||
* @param string $first String to match
|
||||
* @param string $second String to match
|
||||
*
|
||||
* @return string Returns substring representing the longest prefix
|
||||
*/
|
||||
private function _getPrefix($first, $second)
|
||||
{
|
||||
if (mb_strlen($first, CHARSET) == 0 || mb_strlen($second, CHARSET) == 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$index = $this->_getDiffIndex($first, $second);
|
||||
if ($index == -1) {
|
||||
return $first;
|
||||
} elseif ($index == 0) {
|
||||
return '';
|
||||
} else {
|
||||
return mb_substr($first, 0, $index, CHARSET);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Difference Index
|
||||
*
|
||||
* @param string $first String to match
|
||||
* @param string $second String to match
|
||||
*
|
||||
* @return Return index of first difference
|
||||
*/
|
||||
private function _getDiffIndex($first, $second)
|
||||
{
|
||||
if ($first == $second) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
$maxLen = min(mb_strlen($first, CHARSET), mb_strlen($second, CHARSET));
|
||||
for ($i = 0; $i < $maxLen; $i++) {
|
||||
if ($first[$i] != $second[$i]) {
|
||||
return $i;
|
||||
}
|
||||
}
|
||||
|
||||
return $maxLen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print Matrix
|
||||
* Utility / Testing function for testing purposes
|
||||
*
|
||||
* @param array $arr 2-dimensional array representing a matrix
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function _printMatrix($arr)
|
||||
{
|
||||
$str = '';
|
||||
$width = count($arr[0]);
|
||||
$height = count($arr);
|
||||
|
||||
for ($i = 0; $i < $height; $i++) {
|
||||
for ($j = 0; $j < $width; $j++) {
|
||||
if (!isset($arr[$i][$j])) {
|
||||
$arr[$i][$j] = ' ';
|
||||
}
|
||||
|
||||
$str = $str . "[{$arr[$i][$j]}]";
|
||||
|
||||
if ($j === $width - 1) {
|
||||
$str = $str . PHP_EOL;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print($str);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user