Component

Module

Search Box

  1. Your module should include id, created_on, updated_on, and status.
  2. Modules with “Assign To Page” must have a page_id field.
  3. Only modules with page_id, internal_url, or external_url will be stored in wolf_search_result.

Directory

File Folder Link
New Snippet : js-script & search-box & search-result \\SYNAS\Allan\DOCUMENTATION\Component\Search Box\rckuc\snippet
search-icon.png \\SYNAS\Allan\DOCUMENTATION\Component\Search Box\rckuc\public\themes\rckuc\images
default.css \\SYNAS\Allan\DOCUMENTATION\Component\Search Box\rckuc\public\themes\rckuc\css
rckuc.js & dataTable \\SYNAS\Allan\DOCUMENTATION\Component\Search Box\rckuc\public\themes\rckuc\js
index-20251120.php \\SYNAS\Allan\DOCUMENTATION\Component\Search Box\rckuc\wolf\app\views\setting
Page.php & SearchResult.php \\SYNAS\Allan\DOCUMENTATION\Component\Search Box\rckuc\wolf\app\models
SettingController-20251120.php \\SYNAS\Allan\DOCUMENTATION\Component\Search Box\rckuc\wolf\app\controllers
Framework.php \\SYNAS\Allan\DOCUMENTATION\Component\Search Box\rckuc\wolf
wolf_search_result \\SYNAS\Allan\DOCUMENTATION\Component\Search Box

 

Step 1

Create New Snippet: js-script
Add the content of dataTable.js. Later, include this in Layout → search-page.
<!-- DataTables CSS --> <link rel="stylesheet" href="<?php echo THEME_PATH ?>js/jquery.dataTables-1.13.6.min.css"></link> <!-- DataTables JS --> <script src="<?php echo THEME_PATH ?>js/jquery.dataTables-1.13.6.min.js"></script>
<!-- DataTables CSS -->
<link rel="stylesheet" href="<?php echo THEME_PATH ?>js/jquery.dataTables-1.13.6.min.css"></link>
<!-- DataTables JS -->
<script src="<?php echo THEME_PATH ?>js/jquery.dataTables-1.13.6.min.js"></script>

Step 2

Create New Snippet: search-box

After that in Snippet → header, insert: <?php $this->includeSnippet('search-box'); ?>

<form id="search_form" method="post" action="<?php echo get_url('search-result'); ?>"> <input type="text" id="search_box" name="search" placeholder="Search"> <button type="submit"><img src="<?php echo THEME_PATH ?>images/search-icon.png" alt="search"></button> </form>
<form id="search_form" method="post" action="<?php echo get_url('search-result'); ?>">
    <input type="text" id="search_box" name="search" placeholder="Search"> 
    <button type="submit"><img src="<?php echo THEME_PATH ?>images/search-icon.png" alt="search"></button>
</form>

Step 3

Create New Snippet: search-result
This defines the structure of search results. Include it later in Page → Search Result.
<?php if (!empty($_POST['search'])) { $search = $_POST['search']; $results = SearchResult::getResult($search); $keyword = $_POST['search']; } function getFullSlugFromUrl($url) { $path = parse_url($url, PHP_URL_PATH); $path = trim($path, '/'); if (empty($path)) { return ''; } $slug = 'home <span class="breadcrumb-separator">&gt;</span> ' . str_replace('/', '<span class="breadcrumb-separator">&gt;</span>', $path); return $slug; } function getDescription($text, $keyword, $charLimit) { // Normalize spaces $text = preg_replace('/\s+/', ' ', trim($text)); if ($text === '') { return ''; } // If text is shorter than limit, just return if (mb_strlen($text) <= $charLimit) { return ' - ' . $text; } // Detect if keyword is a quoted phrase → exact match $isPhrase = preg_match('/^".+"$/', $keyword); if ($isPhrase) { $searchTerms = [trim($keyword, '"')]; // exact phrase } else { // Split like MySQL full-text (basic: split on non-alphanum, incl. hyphens) $searchTerms = preg_split('/[^[:alnum:]]+/u', mb_strtolower($keyword), -1, PREG_SPLIT_NO_EMPTY); } // Try to find first occurrence of any search term $foundPos = false; $foundLen = 0; foreach ($searchTerms as $term) { $pos = mb_stripos($text, $term); if ($pos !== false && ($foundPos === false || $pos < $foundPos)) { $foundPos = $pos; $foundLen = mb_strlen($term); } } if ($foundPos !== false) { // Calculate snippet around keyword $half = floor($charLimit / 2); $start = max(0, $foundPos - $half); $excerpt = mb_substr($text, $start, $charLimit); // Add ellipses if truncated if ($start > 0) { $excerpt = '...' . ltrim($excerpt); } if ($start + $charLimit < mb_strlen($text)) { $excerpt = rtrim($excerpt) . '...'; } return ' - ' . $excerpt; } // No keyword found → return first $charLimit characters $excerpt = mb_substr($text, 0, $charLimit); return ' - ' . rtrim($excerpt) . '...'; } ?> <div id="search_result"> <h1>Search Result : "<?php echo $keyword; ?>"</h1> <p id="search_error">The search keyword is too short. Please enter at least 4 characters.</p> <input type="hidden" id="keyword" value="<?php echo $keyword; ?>"> <table id="result_table"> <thead> <tr> <th></th> </tr> </thead> <tbody> <?php foreach ($results as $result) : $description = getDescription($result->description, $keyword, 500); ?> <tr> <td> <a href="<?php echo $result->url ;?>" target="_blank"><h3><?php echo $result->title; ?></h3></a> <p><?php echo getFullSlugFromUrl($result->url) ;?></p> <div> <?php echo $result->image !== null ? '<a href="'.$result->url.'" target="_blank"><img src="'.$result->image.'"></a>' : ''; ?> <p><?php echo date("d-M-Y", strtotime($result->created_on)) . $description; ?></p> </div> </td> </tr> <?php endforeach?> </tbody> </table> </div>
<?php 
if (!empty($_POST['search'])) {
    $search = $_POST['search'];
    $results = SearchResult::getResult($search);

    $keyword = $_POST['search'];
}

function getFullSlugFromUrl($url) {
    $path = parse_url($url, PHP_URL_PATH);
    $path = trim($path, '/');
    if (empty($path)) {
        return '';
    }
    $slug = 'home <span class="breadcrumb-separator">&gt;</span> ' . str_replace('/', '<span class="breadcrumb-separator">&gt;</span>', $path);

    return $slug;
}

function getDescription($text, $keyword, $charLimit) {
    // Normalize spaces
    $text = preg_replace('/\s+/', ' ', trim($text));

    if ($text === '') {
        return '';
    }

    // If text is shorter than limit, just return
    if (mb_strlen($text) <= $charLimit) {
        return ' - ' . $text;
    }

    // Detect if keyword is a quoted phrase → exact match
    $isPhrase = preg_match('/^".+"$/', $keyword);
    if ($isPhrase) {
        $searchTerms = [trim($keyword, '"')]; // exact phrase
    } else {
        // Split like MySQL full-text (basic: split on non-alphanum, incl. hyphens)
        $searchTerms = preg_split('/[^[:alnum:]]+/u', mb_strtolower($keyword), -1, PREG_SPLIT_NO_EMPTY);
    }

    // Try to find first occurrence of any search term
    $foundPos = false;
    $foundLen = 0;
    foreach ($searchTerms as $term) {
        $pos = mb_stripos($text, $term);
        if ($pos !== false && ($foundPos === false || $pos < $foundPos)) {
            $foundPos = $pos;
            $foundLen = mb_strlen($term);
        }
    }

    if ($foundPos !== false) {
        // Calculate snippet around keyword
        $half   = floor($charLimit / 2);
        $start  = max(0, $foundPos - $half);
        $excerpt = mb_substr($text, $start, $charLimit);

        // Add ellipses if truncated
        if ($start > 0) {
            $excerpt = '...' . ltrim($excerpt);
        }
        if ($start + $charLimit < mb_strlen($text)) {
            $excerpt = rtrim($excerpt) . '...';
        }

        return ' - ' . $excerpt;
    }

    // No keyword found → return first $charLimit characters
    $excerpt = mb_substr($text, 0, $charLimit);
    return ' - ' . rtrim($excerpt) . '...';
}

?>

<div id="search_result">
    <h1>Search Result : "<?php echo $keyword; ?>"</h1>
    <p id="search_error">The search keyword is too short. Please enter at least 4 characters.</p>
    <input type="hidden" id="keyword" value="<?php echo $keyword; ?>">
    <table id="result_table">
        <thead>
            <tr>
                <th></th>
            </tr>
        </thead>
        <tbody>     
            <?php foreach ($results as $result) :
                $description = getDescription($result->description, $keyword, 500);
                ?>
                <tr>
                    <td>
                        <a href="<?php echo $result->url ;?>" target="_blank"><h3><?php echo $result->title; ?></h3></a>
                        <p><?php echo getFullSlugFromUrl($result->url) ;?></p>
                        <div>
                            <?php echo $result->image !== null ? '<a href="'.$result->url.'" target="_blank"><img src="'.$result->image.'"></a>' : ''; ?>
                            <p><?php echo date("d-M-Y", strtotime($result->created_on)) . $description; ?></p>
                        </div>
                    </td>
                </tr>
            <?php endforeach?>
        </tbody>
    </table>
</div>

Step 4

Create New Layout: search-page

Copy from sub-page and keep only the header and footer.

In the <head> section, after <?php $this->includeSnippet('meta-tag'); ?>, insert:

<?php $this->includeSnippet('js_script'); ?>
<?php $this->includeSnippet('js_script'); ?>

Step 5

Create New Page: "Search Result" under the Page tab

Remember set:

  • Status to Hidden
  • Set Layout to "search-page"

In the body section, insert:

<?php $this->includeSnippet('search-result'); ?>
<?php $this->includeSnippet('search-result'); ?>

Step 6

CSS: default.css

#search_result { margin-top: 6rem; } #search_form { position: absolute; top: 0; left: 33rem; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 10px; background: #f9f9f9; border-radius: 8px; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08); } /* Search box styling */ #search_box { flex: 1; padding: 0.625rem 2.5rem 0.625rem 0.625rem; font-size: 14px; border: 1px solid #ccc; border-radius: 6px; outline: none; transition: border-color 0.2s, box-shadow 0.2s; } #search_box:focus { border-color: #4a90e2; box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2); } /* Button styling */ #search_form button { position: absolute; right: 1.3rem; display: none; border: none; background-color: unset; cursor: pointer; } #search_form button:hover { opacity: 0.7; } #search_form button img { height: 1.3rem; width: 1.3rem; } .highlight { background-color: yellow; font-weight: bold; } table#result_table thead { display: none; } table#result_table td { padding-bottom: 2rem; border-top: 1px solid #ccc; } table#result_table td > div > p { font-size: 14px; color: #8f8f8f; padding: 0.3rem 0; text-align: left; } table#result_table td > div { display: flex; align-items: flex-start; justify-content: flex-start; gap: 1rem; } table#result_table td > div > a { min-width: 5rem; width: 5rem; height: 5rem; padding-top: 0.5rem; } table#result_table td > div > a > img { width: 100%; height: 100%; object-fit: cover; margin: 0; }
#search_result {
	margin-top: 6rem;
}

#search_form {
	position: absolute;
	top: 0;
	left: 33rem;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
    padding: 10px;
    background: #f9f9f9;
    border-radius: 8px;
    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
}

/* Search box styling */
#search_box {
    flex: 1;
    padding: 0.625rem 2.5rem 0.625rem 0.625rem;
    font-size: 14px;
    border: 1px solid #ccc;
    border-radius: 6px;
    outline: none;
    transition: border-color 0.2s, box-shadow 0.2s;
}

#search_box:focus {
    border-color: #4a90e2;
    box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2);
}

/* Button styling */
#search_form button {
	position: absolute;
	right: 1.3rem;
	display: none;
    border: none;
	background-color: unset;
    cursor: pointer;
}

#search_form button:hover {
	opacity: 0.7;
}

#search_form button img {
	height: 1.3rem;
	width: 1.3rem;
}

.highlight {
    background-color: yellow;
    font-weight: bold;
}

table#result_table thead {
	display: none;
}

table#result_table td {
	padding-bottom: 2rem;
	border-top: 1px solid #ccc;
}

table#result_table td > div > p {
	font-size: 14px;
	color: #8f8f8f;
	padding: 0.3rem 0;
	text-align: left;
}

table#result_table td > div {
	display: flex;
	align-items: flex-start;
	justify-content: flex-start;
	gap: 1rem;
}

table#result_table td > div > a {
	min-width: 5rem;
	width: 5rem;
	height: 5rem;
	padding-top: 0.5rem;
}

table#result_table td > div > a > img {
	width: 100%;
	height: 100%;
	object-fit: cover;
	margin: 0;
}

Step 7

Javascript: rckuc.js

// search box $(document).ready(function() { if ($("#search_result table").length) { let search_table = $("#search_result table").DataTable({ "order": [], pageLength: 20, searching: false, // hide Search box lengthChange: false // hide "Show entries" dropdown }) if ($("#search_result #keyword").length && $("#search_result #keyword").val() !== "") { $("#search_box").val($("#search_result #keyword").val()); if (!$("#result_table tbody tr td a").length && $("#search_result #keyword").val().length < 4) { $("#search_result #search_error").show(); } else { highlightSearch(); } } // function highlightSearch() { // let keyword = $("#keyword").val().trim(); // let $result = $("#search_result table td"); // // Remove old highlights // $result.find(".highlight").each(function() { // $(this).replaceWith($(this).text()); // }); // if (keyword === "") return; // // Original keyword (to match exact string in text) // let original = keyword; // // Cleaned keyword (to mimic SQL tokenization) // let base = keyword // .replace(/'s$/i, "") // strip trailing 's // .replace(/[^a-zA-Z0-9]+/g, " ") // remove symbols // .trim() // .split(/\s+/) // .filter(w => w.length > 2); // ignore short words // if (base.length === 0) return; // // Regex for: // // - exact phrase (original) // // - base words + prefix (President, Presidents, Presidential) // let patterns = []; // patterns.push(original.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); // escape regex // base.forEach(w => patterns.push(w + "[a-z]*")); // let regex = new RegExp("(" + patterns.join("|") + ")", "gi"); // // Highlight matches // $result.contents().each(function highlightTextNodes() { // if (this.nodeType === 3) { // let text = this.nodeValue; // let newHtml = text.replace(regex, '<span class="highlight">$1</span>'); // if (newHtml !== text) { // $(this).replaceWith(newHtml); // } // } else { // $(this).contents().each(highlightTextNodes); // } // }); // } function highlightSearch() { let keyword = $("#keyword").val().trim(); let $result = $("#search_result table td"); // Remove old highlights $result.find(".highlight").each(function() { $(this).replaceWith($(this).text()); }); if (keyword === "") return; let patterns = []; // Check if quoted phrase → exact match if (/^".+"$/.test(keyword)) { let phrase = keyword.slice(1, -1); // remove quotes patterns.push(phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); // escape regex } else { // Tokenize similar to MySQL full-text let base = keyword .replace(/'s$/i, "") // strip trailing 's .replace(/[^a-zA-Z0-9]+/g, " ") // split symbols, incl. hyphens .trim() .split(/\s+/) .filter(w => w.length >= 3); // simulate ft_min_word_len // Each term → prefix search (word*) base.forEach(w => { patterns.push(w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "[a-z]*"); }); } if (patterns.length === 0) return; let regex = new RegExp("(" + patterns.join("|") + ")", "gi"); // Highlight matches $result.contents().each(function highlightTextNodes() { if (this.nodeType === 3) { let text = this.nodeValue; let newHtml = text.replace(regex, '<span class="highlight">$1</span>'); if (newHtml !== text) { $(this).replaceWith(newHtml); } } else { $(this).contents().each(highlightTextNodes); } }); } search_table.on("draw", function() { highlightSearch(); }); } })
// search box
$(document).ready(function() {
	if ($("#search_result table").length) {
		let search_table = $("#search_result table").DataTable({
			"order": [],
			pageLength: 20,
			searching: false, // hide Search box
			lengthChange: false // hide "Show entries" dropdown
		})
	
		if ($("#search_result #keyword").length && $("#search_result #keyword").val() !== "") {
			$("#search_box").val($("#search_result #keyword").val());
			if (!$("#result_table tbody tr td a").length && $("#search_result #keyword").val().length < 4) {
				$("#search_result #search_error").show();
			} else {
				highlightSearch();
			}
		}
	
		// function highlightSearch() {
		// 	let keyword = $("#keyword").val().trim();
		// 	let $result = $("#search_result table td");
	
		// 	// Remove old highlights
		// 	$result.find(".highlight").each(function() {
		// 		$(this).replaceWith($(this).text());
		// 	});
	
		// 	if (keyword === "") return;
	
		// 	// Original keyword (to match exact string in text)
		// 	let original = keyword;
	
		// 	// Cleaned keyword (to mimic SQL tokenization)
		// 	let base = keyword
		// 		.replace(/'s$/i, "")           // strip trailing 's
		// 		.replace(/[^a-zA-Z0-9]+/g, " ") // remove symbols
		// 		.trim()
		// 		.split(/\s+/)
		// 		.filter(w => w.length > 2);     // ignore short words
	
		// 	if (base.length === 0) return;
	
		// 	// Regex for:
		// 	// - exact phrase (original)
		// 	// - base words + prefix (President, Presidents, Presidential)
		// 	let patterns = [];
		// 	patterns.push(original.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); // escape regex
		// 	base.forEach(w => patterns.push(w + "[a-z]*"));
	
		// 	let regex = new RegExp("(" + patterns.join("|") + ")", "gi");
	
		// 	// Highlight matches
		// 	$result.contents().each(function highlightTextNodes() {
		// 		if (this.nodeType === 3) {
		// 			let text = this.nodeValue;
		// 			let newHtml = text.replace(regex, '<span class="highlight">$1</span>');
		// 			if (newHtml !== text) {
		// 				$(this).replaceWith(newHtml);
		// 			}
		// 		} else {
		// 			$(this).contents().each(highlightTextNodes);
		// 		}
		// 	});
		// }
	
		function highlightSearch() {
			let keyword = $("#keyword").val().trim();
			let $result = $("#search_result table td");
	
			// Remove old highlights
			$result.find(".highlight").each(function() {
				$(this).replaceWith($(this).text());
			});
	
			if (keyword === "") return;
	
			let patterns = [];
	
			// Check if quoted phrase → exact match
			if (/^".+"$/.test(keyword)) {
				let phrase = keyword.slice(1, -1); // remove quotes
				patterns.push(phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); // escape regex
			} else {
				// Tokenize similar to MySQL full-text
				let base = keyword
					.replace(/'s$/i, "")            // strip trailing 's
					.replace(/[^a-zA-Z0-9]+/g, " ") // split symbols, incl. hyphens
					.trim()
					.split(/\s+/)
					.filter(w => w.length >= 3);    // simulate ft_min_word_len
	
				// Each term → prefix search (word*)
				base.forEach(w => {
					patterns.push(w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "[a-z]*");
				});
			}
	
			if (patterns.length === 0) return;
	
			let regex = new RegExp("(" + patterns.join("|") + ")", "gi");
	
			// Highlight matches
			$result.contents().each(function highlightTextNodes() {
				if (this.nodeType === 3) {
					let text = this.nodeValue;
					let newHtml = text.replace(regex, '<span class="highlight">$1</span>');
					if (newHtml !== text) {
						$(this).replaceWith(newHtml);
					}
				} else {
					$(this).contents().each(highlightTextNodes);
				}
			});
		}
	
	
		search_table.on("draw", function() {
			highlightSearch();
		});
	}
})

Step 8

Import SQL Table: wolf_search_result.sql

After importing, rename "wolf_" to match your project prefix.

Step 9

Create New Module: SearchResult
No need for (V) and (C) create model (M) only
SearchResult.php: 
<?php /** * Frog CMS - Content Management Simplified. <http://www.madebyfrog.com> * Copyright (C) 2008 Philippe Archambault <philippe.archambault@gmail.com> * * This file is part of Frog CMS. * * Frog CMS is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Frog CMS is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Frog CMS. If not, see <http://www.gnu.org/licenses/>. * * Frog CMS has made an exception to the GNU General Public License for plugins. * See exception.txt for details and the full text. */ /** * @package frog * @subpackage models * * @author Philippe Archambault <philippe.archambault@gmail.com> * @version 0.1 * @license http://www.gnu.org/licenses/gpl.html GPL License * @copyright Philippe Archambault, 2008 */ /** * class SearchResult * * @author Philippe Archambault <philippe.archambault@gmail.com> * @since Frog version 0.1 */ class SearchResult extends Record // All properties and methods of Record are available in SearchResult, unless overridden. { const TABLE_NAME = 'search_result'; public static function find($args = null) { // Collect attributes... $where = isset($args['where']) ? trim($args['where']) : ''; $order_by = isset($args['order']) ? trim($args['order']) : ''; $offset = isset($args['offset']) ? (int) $args['offset'] : 0; $limit = isset($args['limit']) ? (int) $args['limit'] : 0; // Prepare query parts $where_string = empty($where) ? '' : "WHERE $where"; $order_by_string = empty($order_by) ? '' : "ORDER BY $order_by"; $limit_string = $limit > 0 ? "LIMIT $offset, $limit" : ''; $tablename = self::tableNameFromClassName('SearchResult'); // Prepare SQL $sql = "SELECT $tablename.* FROM $tablename". " $where_string $order_by_string $limit_string"; $stmt = self::$__CONN__->prepare($sql); $stmt->execute(); // Run! if ($limit == 1) { return $stmt->fetchObject('SearchResult'); } else { $objects = array(); while ($object = $stmt->fetchObject('SearchResult')) { $objects[] = $object; } return $objects; } } // find public static function findAll($args = null) { return self::find($args); } public static function findById($id){ return self::find(array( 'where' => self::tableNameFromClassName('SearchResult').'.id='.(int)$id, 'limit' => 1 )); } public static function findByUrl($url) { $table = self::tableNameFromClassName('SearchResult'); return self::findAll([ 'where' => "$table.url = '" . addslashes($url) . "'" ]); } public static function findByRecordId($id, $module) { $table = self::tableNameFromClassName('SearchResult'); return self::find(array( 'where' => "$table.record_id = " . (int)$id . " AND $table.module = '" . $module . "'", 'limit' => 1 )); } public static function findByPageId($id) { return self::find(array( 'where' => self::tableNameFromClassName('SearchResult').'.page_id='.(int)$id, 'limit' => 1 )); } public static function getResult($keyword) { $pageTablename = self::tableNameFromClassName('SearchResult'); Setting::init(); $external = Setting::get("external_search_result") ? true : false; // Implement a search function with the following rules: // 1. The keyword must match at least one word in either the title or description. // 2. Filter by URL uniqueness with strict validation: // - If multiple records share the same URL, and any of them is invalid: // - module == "Page" && (status != 100 && status != 101) // - module != "Page" && (status != 1) // - Then remove all records that share this URL. // - Otherwise, if multiple records are valid for the same URL, prefer the one where module == "Page". // 3. Exclude external search result if $external == true // 4. The results should be ordered as follows: // - First, by relevance: records where the keyword matches the title should come before those where it matches only the description. // - Then, by updated_on date (most recent first). // - Finally, by created_on date (most recent first). $sql = " SELECT sr.* FROM ( SELECT *, (MATCH(title, description) AGAINST(:kw IN BOOLEAN MODE) * 3) AS relevance, (MATCH(title, description) AGAINST(:kw IN BOOLEAN MODE) > 0) AS title_match FROM `$pageTablename` WHERE MATCH(title, description) AGAINST(:kw IN BOOLEAN MODE) " . ($external ? "" : "AND (external IS NULL OR external = 0 OR external <> 1)") . " ) sr JOIN ( -- Find URLs where all records are valid SELECT url, MAX(CASE WHEN module = 'Page' THEN 2 ELSE 1 END) AS page_priority FROM `$pageTablename` WHERE MATCH(title, description) AGAINST(:kw IN BOOLEAN MODE) " . ($external ? "" : "AND (external IS NULL OR external = 0 OR external <> 1)") . " GROUP BY url HAVING SUM( CASE WHEN (module = 'Page' AND status NOT IN (100, 101)) OR (module <> 'Page' AND status <> 1) THEN 1 ELSE 0 END ) = 0 -- drop URL if any invalid ) uniq ON sr.url = uniq.url AND ((sr.module = 'Page' AND uniq.page_priority = 2) OR (sr.module <> 'Page' AND uniq.page_priority = 1)) ORDER BY sr.title_match DESC, sr.relevance DESC, sr.updated_on DESC, sr.created_on DESC; "; // --- Prepare and clean keyword for boolean search --- $stmt = self::$__CONN__->prepare($sql); // Remove trailing 's $cleanKeyword = preg_replace("/'s$/i", "", $keyword); $cleanKeyword = trim($cleanKeyword); $kw = '"' . $cleanKeyword . '"'; // Bind the correct variable $stmt->execute([ ':kw' => $kw, ]); $objects = []; while ($object = $stmt->fetchObject('SearchResult')) { $objects[] = $object; } return $objects; } } // end SearchResult class
<?php

/**
 * Frog CMS - Content Management Simplified. <http://www.madebyfrog.com>
 * Copyright (C) 2008 Philippe Archambault <philippe.archambault@gmail.com>
 *
 * This file is part of Frog CMS.
 *
 * Frog CMS is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * Frog CMS is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with Frog CMS.  If not, see <http://www.gnu.org/licenses/>.
 *
 * Frog CMS has made an exception to the GNU General Public License for plugins.
 * See exception.txt for details and the full text.
 */

/**
 * @package frog
 * @subpackage models
 *
 * @author Philippe Archambault <philippe.archambault@gmail.com>
 * @version 0.1
 * @license http://www.gnu.org/licenses/gpl.html GPL License
 * @copyright Philippe Archambault, 2008
 */

/**
 * class SearchResult
 *
 * @author Philippe Archambault <philippe.archambault@gmail.com>
 * @since Frog version 0.1
 */
class SearchResult extends Record // All properties and methods of Record are available in SearchResult, unless overridden.
{
    const TABLE_NAME = 'search_result';

    public static function find($args = null)
    {
        // Collect attributes...
        $where    = isset($args['where']) ? trim($args['where']) : '';
        $order_by = isset($args['order']) ? trim($args['order']) : '';
        $offset   = isset($args['offset']) ? (int) $args['offset'] : 0;
        $limit    = isset($args['limit']) ? (int) $args['limit'] : 0;

        // Prepare query parts
        $where_string = empty($where) ? '' : "WHERE $where";
        $order_by_string = empty($order_by) ? '' : "ORDER BY $order_by";
        $limit_string = $limit > 0 ? "LIMIT $offset, $limit" : '';

        $tablename = self::tableNameFromClassName('SearchResult');

        // Prepare SQL
        $sql = "SELECT $tablename.* FROM $tablename".
               " $where_string $order_by_string $limit_string";

		$stmt = self::$__CONN__->prepare($sql);
        $stmt->execute();

        // Run!
        if ($limit == 1)
        {
            return $stmt->fetchObject('SearchResult');
        }
        else
        {
            $objects = array();
            while ($object = $stmt->fetchObject('SearchResult'))
            {
                $objects[] = $object;
            }
            return $objects;
        }

    } // find

    public static function findAll($args = null)
    {
        return self::find($args);
    }

    public static function findById($id){
        return self::find(array(
            'where' => self::tableNameFromClassName('SearchResult').'.id='.(int)$id,
            'limit' => 1
        ));
    }

    public static function findByUrl($url) {
        $table = self::tableNameFromClassName('SearchResult');
        return self::findAll([
            'where' => "$table.url = '" . addslashes($url) . "'"
        ]);
    }

    public static function findByRecordId($id, $module) {
        $table = self::tableNameFromClassName('SearchResult');
        return self::find(array(
            'where' => "$table.record_id = " . (int)$id . " AND $table.module = '" . $module . "'",
            'limit' => 1
        ));
    }
	
	public static function findByPageId($id) {
        return self::find(array(
            'where' => self::tableNameFromClassName('SearchResult').'.page_id='.(int)$id,
            'limit' => 1
        ));
    }

    public static function getResult($keyword) {
        $pageTablename = self::tableNameFromClassName('SearchResult');
        Setting::init();
        $external = Setting::get("external_search_result") ? true : false;

        // Implement a search function with the following rules:
        // 1. The keyword must match at least one word in either the title or description.
        // 2. Filter by URL uniqueness with strict validation:
        //    - If multiple records share the same URL, and any of them is invalid:
        //    - module == "Page" && (status != 100 && status != 101)
        //    - module != "Page" && (status != 1)
        //    - Then remove all records that share this URL.
        //    - Otherwise, if multiple records are valid for the same URL, prefer the one where module == "Page".
        // 3. Exclude external search result if $external == true
        // 4. The results should be ordered as follows:
        //    - First, by relevance: records where the keyword matches the title should come before those where it matches only the description.
        //    - Then, by updated_on date (most recent first).
        //    - Finally, by created_on date (most recent first).

        $sql = "
        SELECT sr.*
        FROM (
            SELECT 
                *,
                (MATCH(title, description) AGAINST(:kw IN BOOLEAN MODE) * 3) AS relevance,
                (MATCH(title, description) AGAINST(:kw IN BOOLEAN MODE) > 0) AS title_match
            FROM `$pageTablename`
            WHERE MATCH(title, description) AGAINST(:kw IN BOOLEAN MODE)
            " . ($external ? "" : "AND (external IS NULL OR external = 0 OR external <> 1)") . "
        ) sr
        JOIN (
            -- Find URLs where all records are valid
            SELECT url, 
                MAX(CASE WHEN module = 'Page' THEN 2 ELSE 1 END) AS page_priority
            FROM `$pageTablename`
            WHERE MATCH(title, description) AGAINST(:kw IN BOOLEAN MODE)
            " . ($external ? "" : "AND (external IS NULL OR external = 0 OR external <> 1)") . "
            GROUP BY url
            HAVING SUM(
                CASE 
                    WHEN (module = 'Page' AND status NOT IN (100, 101)) OR 
                        (module <> 'Page' AND status <> 1)
                    THEN 1 ELSE 0
                END
            ) = 0 -- drop URL if any invalid
        ) uniq
        ON sr.url = uniq.url
        AND ((sr.module = 'Page' AND uniq.page_priority = 2) OR (sr.module <> 'Page' AND uniq.page_priority = 1))
        ORDER BY 
            sr.title_match DESC,
            sr.relevance DESC,
            sr.updated_on DESC,
            sr.created_on DESC;
        ";


        // --- Prepare and clean keyword for boolean search ---
        $stmt = self::$__CONN__->prepare($sql);

        // Remove trailing 's
        $cleanKeyword = preg_replace("/'s$/i", "", $keyword);
        $cleanKeyword = trim($cleanKeyword);
        $kw = '"' . $cleanKeyword . '"';

        // Bind the correct variable
        $stmt->execute([
            ':kw' => $kw,
        ]);

        $objects = [];

        while ($object = $stmt->fetchObject('SearchResult')) {
            $objects[] = $object;
        }
        return $objects;
    }
} // end SearchResult class
                

Step 10

Update: Framework.php → class Record {}:

  • Update your afterSave()
  • Add 5 new functions:

    1. saveSearchResult()

    2. getSearchContent()

    3. isInternalUrl()

    4. getUrl()

    5. getOgImgFromPageId() (adjust banner image path if needed)

  • Update your afterDelete()
  • Add new function: deleteSearchResult()
/** * 20250919.Allan * Allows sub-classes do stuff after a Record is saved. * * @return boolean True if the actions succeeded. */ public function afterSave() { Setting::init(); $search_module = json_decode(Setting::get('search_module'), true); $allowedModules = array_keys($search_module); $allowedModules = array_merge(['Page', 'PagePart'], $allowedModules); // Get the model name $module_name = (new \ReflectionClass($this))->getShortName(); if (in_array($module_name, $allowedModules)) { $this->saveSearchResult($module_name); } // To show warning if module status is hidden if ($module_name == "Page" && ($this->status_id == 100 || $this->status_id == 101)) { $page_search_result = SearchResult::findByRecordId((int)$this->id, "Page"); $search_results = SearchResult::findByUrl($page_search_result->url); foreach ($search_results as $result) { if ($result->module !== "Page" && (string)$result->status === "0") { Flash::set("error", ("This ".preg_replace('/(?<!^)([A-Z])/', ' $1', $result->module)." status is Hidden!")); } } } return true; } public function saveSearchResult($module_name) { $page_id = null; $module_status = null; $internal_page = false; $external_page = false; $page = null; $page_search_result = null; $module_search_result = null; // check if saving Page if ($module_name == 'Page') { $page_id = $this->id; $internal_page = true; // check if saving PagePart } else if ($module_name == 'PagePart') { $page_id = $this->page_id; $internal_page = true; // else get module's page_id } else if (!empty($this->page_id)) { $page_id = $this->page_id; $module_status = $this->status; $internal_page = true; } Setting::init(); $search_module = json_decode(Setting::get('search_module'), true); $search_fields = []; foreach ($search_module as $name => $fields) { if ($name == $module_name) { $search_fields = $fields; } } $image = URL_PUBLIC.'public/default_search_img.webp'; // get image path if (!empty($search_fields['image_path'])) { $image = str_replace( [':module', ':id', ':filename'], [ strtolower(preg_replace('/([a-z])([A-Z])/', '$1_$2', $module_name)), $this->id, $this->{$search_fields['image']} ], $search_fields['image_path'] ); // remove double slashes and trailing slash $image = preg_replace('#/+#', '/', $image); $image = rtrim($image, '/'); $image = URL_PUBLIC . ltrim($image, '/'); } $search_data = null; if (!empty($search_fields)) { $search_data = [ 'record_id' => $this->id, 'module' => $module_name, 'title' => $this->{$search_fields['title']}, 'url' => $this->{$search_fields['url']}, 'external' => "", 'image' => $image, 'description' => $this->{$search_fields['description']}, 'created_on' => $this->created_on, 'updated_on' => $this->updated_on, 'status' => $this->status, ]; // If no page_id then get url - check if internal if (!empty($this->{$search_fields['url']}) && $this->isInternalUrl($this->{$search_fields['url']})) { $uri = parse_url($this->{$search_fields['url']}, PHP_URL_PATH); $uri = trim($uri, '/'); if (substr($uri, 0, strlen('rckuc/')) === 'rckuc/') { $uri = substr($uri, strlen('rckuc/')); } $page = Page::findByModuleLink($uri); if ($page) { $page_id = $page->id; $module_status = $this->status; $internal_page = true; } // Check if external } else if (!empty($this->{$search_fields['url']}) && !$this->isInternalUrl($this->{$search_fields['url']})) { $external_page = true; } } if ($internal_page) { $page = Page::findById((int)$page_id); $url = URL_PUBLIC . (USE_MOD_REWRITE ? '' : '?') . $page->getUri() . ($page->getUri() ? URL_SUFFIX : ''); $search_content = $page->getSearchContent($url, $module_status, $page); $page_search_result = SearchResult::findByRecordId($page_id, 'Page'); $module_search_result = $search_data ? SearchResult::findByRecordId($this->id, $module_name) : null; // Handle page $page_search_data = [ 'record_id' => $page_id, 'module' => 'Page', 'title' => $page->title, 'url' => $url, 'image' => $page->getOgImgFromPageId((int)$page_id) ? $page->getOgImgFromPageId((int)$page_id) : $image, 'description' => $search_content, 'created_on' => $page->created_on, 'updated_on' => $page->updated_on, 'status' => $page->status_id, ]; // Handle modules (newsevent, project, etc.) if ($search_data) { if (!empty($this->{$map['image']})) { $page_search_data['image'] = URL_PUBLIC.'public/'.strtolower(preg_replace('/([a-z])([A-Z])/', '$1_$2', $module_name)).'/'.$map['image'].'/'.$this->id.'/'.$this->{$map['image']}; } $search_data['description'] = $search_content; $search_data['url'] = $url; if ($module_search_result) { $module_search_result->setFromData($search_data); } else { $module_search_result = new SearchResult($search_data); } } if ($page_search_result) { $page_search_result->setFromData($page_search_data); } else { $page_search_result = new SearchResult($page_search_data); } } else if ($external_page) { $module_search_result = SearchResult::findByRecordId($this->id, $module_name); $search_data['external'] = 1; if ($module_search_result) { $module_search_result->setFromData($search_data); } else { $module_search_result = new SearchResult($search_data); } } if ($page_search_result) { $page_search_result->save(); } if ($module_search_result) { $module_search_result->save(); } } public function getSearchContent($url, $module_status = null, $page) { $html = @file_get_contents($url); $search_content = ''; if ($html) { // Parse HTML safely libxml_use_internal_errors(true); $doc = new DOMDocument(); $doc->loadHTML($html, LIBXML_NOERROR | LIBXML_NOWARNING); libxml_clear_errors(); $xpath = new DOMXPath($doc); // Remove unwanted tags $removeQueries = [ '//script', '//style', '//body/div[@id="header-holder"]', '//body/div[@id="banner-zone"]', '//body/div[@id="banner-content"]', '//body/div[@id="banner-contant"]', '//body/div[contains(@class,"breadcrumb")]', '//body/div[@id="footer"]', '//body/div[@id="copyright"]' ]; foreach ($removeQueries as $query) { foreach ($xpath->query($query) as $node) { $node->parentNode->removeChild($node); } } // Collect visible text nodes, ensure spacing $nodes = $xpath->query('//body//text()'); $texts = []; foreach ($nodes as $node) { $val = trim($node->nodeValue); if ($val !== '') { $texts[] = $val; } } $search_content = preg_replace('/\s+/', ' ', implode(' ', $texts)); $search_content = strip_tags($search_content); } $blocked = [ Page::STATUS_DRAFT => 'Draft', Page::STATUS_PREVIEW => 'Preview', Page::STATUS_ARCHIVED => 'Archived' ]; // if ((string)$module_status === "1") { // if module status show → inform if page status blocked // if (isset($blocked[$page->status_id])) { // Flash::set('error', __("Page Status : \"{$blocked[$page->status_id]}\". Change the status to \"Published\" or \"Hidden\" to make it searchable.")); // return $search_content; // } else { // return $search_content; // } // } else if ((string)$module_status === "0") { // if module status hidden → archive page // // if ($page->status_id != Page::STATUS_ARCHIVED) { // // unset($page->updated_by_name, $page->created_by_name, $page->part); // // $page->status_id = Page::STATUS_ARCHIVED; // // $page->save(); // // Flash::set('error', __("Page status updated to \"Archived\".")); // // return $search_content; // // } else { // // Flash::set('error', __("Page status remains \"Archived\".")); // // return $search_content; // // } // return $search_content; // } else { // // if no $module_status then it is $page // // if (isset($blocked[$page->status_id])) { // // $search_results = SearchResult::findByUrl($url); // // foreach ($search_results as $result) { // // $result->status = null; // // $result->save(); // // } // // } // return $search_content; // } if ((string)$module_status === "1" && isset($blocked[$page->status_id])) { Flash::set('error', __("This Page Status : \"{$blocked[$page->status_id]}\"!")); } else { return $search_content; } } public function isInternalUrl($url) { $host = parse_url($url, PHP_URL_HOST); $baseHost = parse_url(URL_PUBLIC, PHP_URL_HOST); return $host && strcasecmp($host, $baseHost) === 0; } public function getUrl() { return URL_PUBLIC . (USE_MOD_REWRITE ? '' : '?') . $this->getUri() . ($this->getUri() ? URL_SUFFIX : ''); } public function getOgImgFromPageId($id) { $bannerImage = ''; if ($id == 1) { $banner = Banner::getHomeMetaTagImage(); $bannerImage = URL_PUBLIC.'public/banner/banner_image/'.$banner->id.'/'.$banner->banner_image; } else { $banner = Banner::findByPageId($id); if (empty($banner)) { $banner = Banner::getHomeMetaTagImage(); } $bannerImage = URL_PUBLIC.'public/banner/banner_image/'.$banner->id.'/'.$banner->banner_image; } if (!empty($bannerImage)) { return $bannerImage; } else { return null; } } /** * Allows sub-classes do stuff after a Record is deleted. * * @return boolean True if the actions succeeded. */ public function afterDelete() { Setting::init(); $search_module = json_decode(Setting::get('search_module'), true); $allowedModules = array_keys($search_module); $allowedModules = array_merge(['Page', 'PagePart'], $allowedModules); // Get the model name $module_name = (new \ReflectionClass($this))->getShortName(); if (in_array($module_name, $allowedModules)) { $this->deleteSearchResult($module_name); } return true; } public function deleteSearchResult($module_name) { $search_result = SearchResult::findByRecordId($this->id, $module_name); if ($search_result->delete()) { Flash::set('error', __("This Search Result Deleted.")); } }
    /**
     * 20250919.Allan 
     * Allows sub-classes do stuff after a Record is saved.
     *
     * @return boolean True if the actions succeeded.
     */
    public function afterSave() {
        Setting::init();
        $search_module = json_decode(Setting::get('search_module'), true);
        $allowedModules = array_keys($search_module);
        $allowedModules = array_merge(['Page', 'PagePart'], $allowedModules);

        // Get the model name
        $module_name = (new \ReflectionClass($this))->getShortName();

        if (in_array($module_name, $allowedModules)) {
            $this->saveSearchResult($module_name);
        }
        
        // To show warning if module status is hidden
        if ($module_name == "Page" && ($this->status_id == 100 || $this->status_id == 101)) {
            $page_search_result = SearchResult::findByRecordId((int)$this->id, "Page");
            $search_results = SearchResult::findByUrl($page_search_result->url);
            
            foreach ($search_results as $result) {
                if ($result->module !== "Page" && (string)$result->status === "0") {
                    Flash::set("error", ("This ".preg_replace('/(?<!^)([A-Z])/', ' $1', $result->module)." status is Hidden!"));
                }
            }
        }

        return true;
    }

    public function saveSearchResult($module_name) {
        $page_id = null;
        $module_status = null;
        $internal_page = false;
        $external_page = false;

        $page = null;
        $page_search_result = null;
        $module_search_result = null;

        // check if saving Page
        if ($module_name == 'Page') {
            $page_id = $this->id;
            $internal_page = true;
        // check if saving PagePart
        } else if ($module_name == 'PagePart') {
            $page_id = $this->page_id;
            $internal_page = true;
        // else get module's page_id
        } else if (!empty($this->page_id)) {
            $page_id = $this->page_id;
            $module_status = $this->status;
            $internal_page = true;
        }

        Setting::init();
        $search_module = json_decode(Setting::get('search_module'), true);
        $search_fields = [];
        foreach ($search_module as $name => $fields) {
            if ($name == $module_name) {
                $search_fields = $fields;
            }
        }

        $image = URL_PUBLIC.'public/default_search_img.webp';

        // get image path
        if (!empty($search_fields['image_path'])) {
            $image = str_replace(
                [':module', ':id', ':filename'],
                [
                    strtolower(preg_replace('/([a-z])([A-Z])/', '$1_$2', $module_name)),
                    $this->id,
                    $this->{$search_fields['image']}
                ],
                $search_fields['image_path']
            );

            // remove double slashes and trailing slash
            $image = preg_replace('#/+#', '/', $image);
            $image = rtrim($image, '/');

            $image = URL_PUBLIC . ltrim($image, '/');
        }

        $search_data = null;
        if (!empty($search_fields)) {
            $search_data = [
                'record_id'   => $this->id,
                'module'      => $module_name,
                'title'       => $this->{$search_fields['title']},
                'url'         => $this->{$search_fields['url']},
                'external'    => "",
                'image'       => $image,
                'description' => $this->{$search_fields['description']},
                'created_on'  => $this->created_on,
                'updated_on'  => $this->updated_on,
                'status'      => $this->status,
            ];


            // If no page_id then get url - check if internal
            if (!empty($this->{$search_fields['url']}) && $this->isInternalUrl($this->{$search_fields['url']})) {
                $uri = parse_url($this->{$search_fields['url']}, PHP_URL_PATH); 
                $uri = trim($uri, '/');
                if (substr($uri, 0, strlen('rckuc/')) === 'rckuc/') {
                    $uri = substr($uri, strlen('rckuc/'));
                }
                    $page = Page::findByModuleLink($uri);
                    if ($page) {
                        $page_id = $page->id;
                        $module_status = $this->status;
                        $internal_page = true;
                    }
            // Check if external
            } else if (!empty($this->{$search_fields['url']}) && !$this->isInternalUrl($this->{$search_fields['url']})) {
                $external_page = true;
            }
        }
        
        if ($internal_page) {
            $page = Page::findById((int)$page_id);
            $url = URL_PUBLIC . (USE_MOD_REWRITE ? '' : '?') . $page->getUri() . ($page->getUri() ? URL_SUFFIX : '');
            $search_content = $page->getSearchContent($url, $module_status, $page);
            
            $page_search_result = SearchResult::findByRecordId($page_id, 'Page');
            $module_search_result  = $search_data ? SearchResult::findByRecordId($this->id, $module_name) : null;
            
            // Handle page
            $page_search_data = [
                'record_id' => $page_id,
                'module' => 'Page',
                'title' => $page->title,
                'url' => $url,
                'image' => $page->getOgImgFromPageId((int)$page_id) ? $page->getOgImgFromPageId((int)$page_id) : $image,
                'description' => $search_content,
                'created_on' => $page->created_on,
                'updated_on' => $page->updated_on,
                'status' => $page->status_id,
            ];

            // Handle modules (newsevent, project, etc.)
            if ($search_data) {
                if (!empty($this->{$map['image']})) {
                    $page_search_data['image'] = URL_PUBLIC.'public/'.strtolower(preg_replace('/([a-z])([A-Z])/', '$1_$2', $module_name)).'/'.$map['image'].'/'.$this->id.'/'.$this->{$map['image']};
                }
                $search_data['description'] = $search_content;
                $search_data['url'] = $url;

                if ($module_search_result) {
                    $module_search_result->setFromData($search_data);
                } else {
                    $module_search_result = new SearchResult($search_data);
                }
            }
            
            if ($page_search_result) {
                $page_search_result->setFromData($page_search_data);
            } else {
                $page_search_result = new SearchResult($page_search_data);
            }
        } else if ($external_page) {
            $module_search_result = SearchResult::findByRecordId($this->id, $module_name);
            
            $search_data['external'] = 1;

            if ($module_search_result) {
                $module_search_result->setFromData($search_data);
            } else {
                $module_search_result = new SearchResult($search_data);
            }
        }

        if ($page_search_result) {
            $page_search_result->save();
        }

        if ($module_search_result) {
            $module_search_result->save();
        }
    }

    public function getSearchContent($url, $module_status = null, $page) {
        $html = @file_get_contents($url);

        $search_content = '';

        if ($html) {
            // Parse HTML safely
            libxml_use_internal_errors(true);
            $doc = new DOMDocument();
            $doc->loadHTML($html, LIBXML_NOERROR | LIBXML_NOWARNING);
            libxml_clear_errors();
    
            $xpath = new DOMXPath($doc);
    
            // Remove unwanted tags
            $removeQueries = [
                '//script',
                '//style',
                '//body/div[@id="header-holder"]',
                '//body/div[@id="banner-zone"]',
                '//body/div[@id="banner-content"]',
                '//body/div[@id="banner-contant"]',
                '//body/div[contains(@class,"breadcrumb")]',
                '//body/div[@id="footer"]',
                '//body/div[@id="copyright"]'
            ];
    
            foreach ($removeQueries as $query) {
                foreach ($xpath->query($query) as $node) {
                    $node->parentNode->removeChild($node);
                }
            }
    
            // Collect visible text nodes, ensure spacing
            $nodes = $xpath->query('//body//text()');
            $texts = [];
            foreach ($nodes as $node) {
                $val = trim($node->nodeValue);
                if ($val !== '') {
                    $texts[] = $val;
                }
            }
            $search_content = preg_replace('/\s+/', ' ', implode(' ', $texts));
            $search_content = strip_tags($search_content);
        }
        
        $blocked = [
            Page::STATUS_DRAFT    => 'Draft',
            Page::STATUS_PREVIEW  => 'Preview',
            Page::STATUS_ARCHIVED => 'Archived'
        ];
        // if ((string)$module_status === "1") { // if module status show → inform if page status blocked
        //     if (isset($blocked[$page->status_id])) {
        //         Flash::set('error', __("Page Status : \"{$blocked[$page->status_id]}\". Change the status to \"Published\" or \"Hidden\" to make it searchable."));
        //         return $search_content;
        //     } else {
        //         return $search_content;
        //     }
        // } else if ((string)$module_status === "0") { // if module status hidden → archive page
        //     // if ($page->status_id != Page::STATUS_ARCHIVED) {
        //     //     unset($page->updated_by_name, $page->created_by_name, $page->part);
        //     //     $page->status_id = Page::STATUS_ARCHIVED;
        //     //     $page->save();
        //     //     Flash::set('error', __("Page status updated to \"Archived\"."));
        //     //     return $search_content;
        //     // } else {
        //     //     Flash::set('error', __("Page status remains \"Archived\"."));
        //     //     return $search_content;
        //     // }
        //     return $search_content;
        // } else {
        //     // if no $module_status then it is $page
        //     // if (isset($blocked[$page->status_id])) {
        //     //     $search_results = SearchResult::findByUrl($url);
                
        //     //     foreach ($search_results as $result) {
        //     //         $result->status = null;
        //     //         $result->save();
        //     //     }
        //     // }
        //     return $search_content;
        // }

        if ((string)$module_status === "1" && isset($blocked[$page->status_id])) {
            Flash::set('error', __("This Page Status : \"{$blocked[$page->status_id]}\"!"));
        } else {
            return $search_content;
        }
    }

    public function isInternalUrl($url) {
		$host = parse_url($url, PHP_URL_HOST);
		$baseHost = parse_url(URL_PUBLIC, PHP_URL_HOST);
		
		return $host && strcasecmp($host, $baseHost) === 0;
	}

    public function getUrl() {
        return URL_PUBLIC . (USE_MOD_REWRITE ? '' : '?') . $this->getUri() . ($this->getUri() ? URL_SUFFIX : '');
    }

    public function getOgImgFromPageId($id) {
        $bannerImage = '';
        if ($id == 1) {
            $banner = Banner::getHomeMetaTagImage();
            $bannerImage = URL_PUBLIC.'public/banner/banner_image/'.$banner->id.'/'.$banner->banner_image;
        } else {
            $banner = Banner::findByPageId($id);
            if (empty($banner)) {
                $banner = Banner::getHomeMetaTagImage();
            }
            $bannerImage = URL_PUBLIC.'public/banner/banner_image/'.$banner->id.'/'.$banner->banner_image;
        }
        if (!empty($bannerImage)) {
            return $bannerImage;
        } else {
            return null;
        } 
    }

    /**
     * Allows sub-classes do stuff after a Record is deleted.
     *
     * @return boolean True if the actions succeeded.
     */
    public function afterDelete() { 
        Setting::init();
        $search_module = json_decode(Setting::get('search_module'), true);
        $allowedModules = array_keys($search_module);
        $allowedModules = array_merge(['Page', 'PagePart'], $allowedModules);

        // Get the model name
        $module_name = (new \ReflectionClass($this))->getShortName();

        if (in_array($module_name, $allowedModules)) {
            $this->deleteSearchResult($module_name);
        }

        return true; 
    }

    public function deleteSearchResult($module_name) {
        $search_result = SearchResult::findByRecordId($this->id, $module_name);
        if ($search_result->delete()) {
            Flash::set('error', __("This Search Result Deleted."));
        }
    }

Step 11

Update Model: Page.php

Add 2 new functions:

1. findByModuleLink()

2. findBySlugWithAllStatus()

Step 12

Update Module: "Setting"

If your role is "Admin", update setting->index.php:
Add a new section for "Search Result Setting":
1. A textarea for “Module To Search” (stores JSON mapping data)
2. A switch button to on/off external search result
3. A button to call syncSearchResult()
If your role is not "Admin", you can insert name and value in database "wolf_setting" via phpMyAdmin.

index.php updated on 20/11/2025 refer file index-20251120.php
<tr> <td colspan="3"><h3><?php echo __('Search Result Setting'); ?></h3></td> </tr> <tr> <td class="label"><label for="search_module"><?php echo __('Module To Search'); ?></label></td> <td class="field"> <textarea class="textbox" id="search_module" name="setting[search_module]" rows="10" cols="10" placeholder="Enter Module Name and saperate by comma ','"><?php echo htmlentities(Setting::get('search_module'), ENT_COMPAT, 'UTF-8'); ?></textarea><br> <div class="switch_container"> <label class="switch"> <input type="checkbox" name="setting[external_search_result]" <?php echo htmlentities(Setting::get('external_search_result'), ENT_COMPAT, 'UTF-8') == 1 ? 'checked' : ''; ?> value="1"> <span class="switch_slider round"></span> </label> <label class="switch_label">Include External Search Result<label> </div> <div id="sync_button_list"> <button type="button" onclick="window.location.href='<?php echo get_url('setting/syncSearchResult'); ?>'">Sync All Search Result</button> <button type="button" onclick="window.location.href='<?php echo get_url('setting/syncSearchResult/Page'); ?>'">Sync 'Page' Search Result</button> </div> </td> <td class="help"><?php echo __("Example:"); ?> <br><br> <?php echo __('{'); ?> <br> <?php echo __('"Newsevent": {"title": "title", "description": "description","image": "newsevent_image", "image_path": "public/:module/newsevent_image/:id/:filename"},'); ?> <br> <?php echo __('"Project": {"title": "title", "description": "description", "image": "image"}, "image_path": "public/:module/images/:id/:filename"'); ?> <br> <?php echo __('"BusinessProfile": {"title": "company_name", "description": "short_description", "url": "website", "image": "business_logo", "image_path": "public/:module/business_logo/:id/:filename"}'); ?> <br> <?php echo __('}'); ?> <br><br> <?php echo __("Your Module should have : id, created_on, updated_on & status."); ?> </td> </tr> <script> $(document).ready(function() { function updateSyncButton() { let json = JSON.parse($("#search_module").val() || '{}'); console.log(json); let modules = Object.keys(json); // <- NOW it has values let urlBase = "<?php echo get_url('setting/syncSearchResult/'); ?>"; // PHP runs first $("#sync_button_list button:gt(1)").remove(); modules.forEach(function(module) { let url = urlBase + module; let button = $(` <button type="button" onclick="window.location.href='${url}'"> Sync '${module}' Search Result </button> `); $("#sync_button_list").append(button); }); } updateSyncButton(); }) </script>
<tr>
    <td colspan="3"><h3><?php echo __('Search Result Setting'); ?></h3></td>
</tr>
<tr>
    <td class="label"><label for="search_module"><?php echo __('Module To Search'); ?></label></td>
    <td class="field">
        <textarea class="textbox" id="search_module" name="setting[search_module]" rows="10" cols="10" placeholder="Enter Module Name and saperate by comma ','"><?php echo htmlentities(Setting::get('search_module'), ENT_COMPAT, 'UTF-8'); ?></textarea><br>
        <div class="switch_container">
            <label class="switch">
                <input type="checkbox" name="setting[external_search_result]" <?php echo htmlentities(Setting::get('external_search_result'), ENT_COMPAT, 'UTF-8') == 1 ? 'checked' : ''; ?> value="1">
                <span class="switch_slider round"></span>
            </label>
            <label class="switch_label">Include External Search Result<label>
        </div>
        <div id="sync_button_list">
           <button type="button" onclick="window.location.href='<?php echo get_url('setting/syncSearchResult'); ?>'">Sync All Search Result</button>
           <button type="button" onclick="window.location.href='<?php echo get_url('setting/syncSearchResult/Page'); ?>'">Sync 'Page' Search Result</button>
        </div>
    </td>
    <td class="help"><?php echo __("Example:"); ?> <br><br> 
        <?php echo __('{'); ?> <br>
        <?php echo __('"Newsevent": {"title": "title", "description": "description","image": "newsevent_image", "image_path": "public/:module/newsevent_image/:id/:filename"},'); ?> <br>
        <?php echo __('"Project": {"title": "title", "description": "description", "image": "image"}, "image_path": "public/:module/images/:id/:filename"'); ?> <br>
        <?php echo __('"BusinessProfile": {"title": "company_name", "description": "short_description", "url": "website", "image": "business_logo", "image_path": "public/:module/business_logo/:id/:filename"}'); ?> <br>
        <?php echo __('}'); ?> <br><br>
        <?php echo __("Your Module should have : id, created_on, updated_on & status."); ?>
    </td>
</tr>

<script>
$(document).ready(function() {
    function updateSyncButton() {
        let json = JSON.parse($("#search_module").val() || '{}');
        console.log(json);

        let modules = Object.keys(json); // <- NOW it has values

        let urlBase = "<?php echo get_url('setting/syncSearchResult/'); ?>"; // PHP runs first

        $("#sync_button_list button:gt(1)").remove();  

        modules.forEach(function(module) {
            let url = urlBase + module;

            let button = $(`
                <button type="button"
                        onclick="window.location.href='${url}'">
                    Sync '${module}' Search Result
                </button>
            `);

            $("#sync_button_list").append(button);
        });
    }

    updateSyncButton();
})
</script>

Step 13

Insert Record in Database: wolf_setting

INSERT INTO `wolf_setting` (`name`, `value`) VALUES ('search_module', ''), ('external_search_result', '1');
INSERT INTO `wolf_setting` (`name`, `value`) VALUES ('search_module', ''), ('external_search_result', '1');

Step 14

Example JSON:

Modules with external links (Website / Read More URL) must define url in the JSON mapping.

Image path for ":model", ":id", ":filename", wiill be replace

{ "Newsevent": { "title": "title", "description": "description", "url": "url", "image": "newsevent_image", "image_path": "public/:module/newsevent_image/:id/:filename" }, "Project": { "title": "title", "description": "description", "image": "image", "image_path": "public/:module/images/:id/:filename" } }
  {
    "Newsevent": {
        "title": "title",
        "description": "description",
        "url": "url",
        "image": "newsevent_image",
        "image_path": "public/:module/newsevent_image/:id/:filename"
    },
    "Project": {
        "title": "title",
        "description": "description",
        "image": "image",
        "image_path": "public/:module/images/:id/:filename"
    }
  }
      

Step 15

Update: SettingController.php

Update _save() function
Add new function: syncSearchResult()

This gathers all search results and saves them into "wolf_search_result".
If your role is not Admin, add this function in any controller then call it via URL.
Example if I add to banner controller: 
https://allan.webdesignkuching.com/rckuc/admin/banner/syncSearchResult

syncSearchResult() undated on 20/11/2025 refer file SettingController-20251120.php
// Add this in your _save() function if (empty($data['external_search_result'])){ $data['external_search_result'] = 0; } public function syncSearchResult($selected_module) { Setting::init(); $search_modules = json_decode(Setting::get('search_module'), true) ?: []; $modules = []; if (!empty($selected_module)) { $modules[] = $selected_module; } else { // Collect module names from $search_modules and ensure 'Page' is included $modules = array_unique(array_merge(['Page'], array_keys($search_modules))); } $chunkSize = 5; // number of records per batch foreach ($modules as $module) { if (!is_string($module) || !class_exists($module)) continue; $offset = 0; do { // Fetch a chunk of records $records = $module::findAll([ 'limit' => $chunkSize, 'offset' => $offset ]); if (empty($records)) break; foreach ($records as $record) { if (is_object($record) && method_exists($record, 'saveSearchResult')) { $record->saveSearchResult($module); } } $offset += $chunkSize; } while (true); } $moduleName = !empty($selected_module) ? '"' . $selected_module . '" ' : ''; Flash::set('success', __('All ' . $moduleName . 'Search Results Have Been Synced!')); redirect(get_url('setting')); }
// Add this in your _save() function
if (empty($data['external_search_result'])){
    $data['external_search_result'] = 0;
}

    public function syncSearchResult($selected_module) {
        Setting::init();
        $search_modules = json_decode(Setting::get('search_module'), true) ?: [];

        $modules = [];

        if (!empty($selected_module)) {
            $modules[] = $selected_module;
        } else {
            // Collect module names from $search_modules and ensure 'Page' is included
            $modules = array_unique(array_merge(['Page'], array_keys($search_modules)));
        }

        $chunkSize = 5; // number of records per batch

        foreach ($modules as $module) {
            if (!is_string($module) || !class_exists($module)) continue;

            $offset = 0;
            do {
                // Fetch a chunk of records
                $records = $module::findAll([
                    'limit'  => $chunkSize,
                    'offset' => $offset
                ]);

                if (empty($records)) break;

                foreach ($records as $record) {
                    if (is_object($record) && method_exists($record, 'saveSearchResult')) {
                        $record->saveSearchResult($module);
                    }
                }

                $offset += $chunkSize;
            } while (true);
        }

        $moduleName = !empty($selected_module) ? '"' . $selected_module . '" ' : '';
        Flash::set('success', __('All ' . $moduleName . 'Search Results Have Been Synced!'));
        redirect(get_url('setting'));
    }
Code Copied To Clipboard!