From c40e0a7e5675918b5aade0e47ca98ddc083ff7bd Mon Sep 17 00:00:00 2001 From: anthony-tom Date: Fri, 27 Feb 2026 19:30:26 +0000 Subject: [PATCH 1/6] Update fuzzy.js with redundancy removal Removes redundant (tokenLength <= 2) conditional branch, as the logic is already accounted for by (tokenLength <= 5) --- src/search/fuzzy.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/search/fuzzy.js b/src/search/fuzzy.js index 849df1a608..0a4246dbdf 100644 --- a/src/search/fuzzy.js +++ b/src/search/fuzzy.js @@ -18,7 +18,6 @@ function levenshtein(a, b) { } function maxFuzzyEdits(tokenLength) { - if (tokenLength <= 2) return 1; if (tokenLength <= 5) return 1; if (tokenLength <= 9) return 2; return 3; From 54de545cd5f27b96479f5204d018e70b591484ae Mon Sep 17 00:00:00 2001 From: anthony-tom Date: Fri, 27 Feb 2026 16:14:09 -0500 Subject: [PATCH 2/6] Fix: Dynamic fuzzy search word highlighting - Highlights matched characters in search results when "Fuzzy match" is selected. - For every input search, similar words in post results are highlighted. - Uses Levenshtein minimal edit distance algorithm to determine word similarity --- public/src/client/search.js | 4 +- public/src/modules/search.js | 138 ++++++++++++++++-- src/controllers/search.js | 1 + .../templates/partials/search-results.tpl | 2 +- .../templates/partials/search-results.tpl | 2 +- 5 files changed, 134 insertions(+), 13 deletions(-) diff --git a/public/src/client/search.js b/public/src/client/search.js index d2c1bb1c03..4aca6a72bd 100644 --- a/public/src/client/search.js +++ b/public/src/client/search.js @@ -23,9 +23,11 @@ define('forum/search', [ }); const searchQuery = $('#results').attr('data-search-query'); + const matchWords = $('#results').attr('data-match-words') || 'all'; searchModule.highlightMatches( searchQuery, - $('.search-results .content p, .search-results .topic-title') + $('.search-results .content p, .search-results .topic-title'), + matchWords ); $('#advanced-search form').off('submit').on('submit', function (e) { diff --git a/public/src/modules/search.js b/public/src/modules/search.js index 0c8a6d4f72..f43fded297 100644 --- a/public/src/modules/search.js +++ b/public/src/modules/search.js @@ -175,7 +175,7 @@ define('search', [ const highlightEls = quickSearchResults.find( '.quick-search-results .quick-search-title, .quick-search-results .snippet' ); - Search.highlightMatches(options.searchOptions.term, highlightEls); + Search.highlightMatches(options.searchOptions.term, highlightEls, options.searchOptions.matchWords); hooks.fire('action:search.quick.complete', { data: data, options: options, @@ -314,16 +314,110 @@ define('search', [ } }; - Search.highlightMatches = function (searchQuery, els) { + function getLevenshteinDistance(a, b) { + if (a.length === 0) return b.length; + if (b.length === 0) return a.length; + const m = a.length; + const n = b.length; + const d = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0)); + for (let i = 0; i <= m; i++) d[i][0] = i; + for (let j = 0; j <= n; j++) d[0][j] = j; + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost); + } + } + return d[m][n]; + } + + function maxFuzzyEdits(len) { + if (len <= 5) return 1; + if (len <= 9) return 2; + return 3; + } + + function getFuzzyMatchRanges(query, text) { + const q = String(query || '').toLowerCase(); + const t = String(text || '').toLowerCase(); + if (!q.length || !t.length) return []; + + const m = q.length; + const n = t.length; + const d = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0)); + for (let i = 0; i <= m; i++) d[i][0] = i; + for (let j = 0; j <= n; j++) d[0][j] = j; + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + const cost = q[i - 1] === t[j - 1] ? 0 : 1; + d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost); + } + } + + const dist = d[m][n]; + const allowed = maxFuzzyEdits(m); + if (dist > allowed) return []; + + if (m > n) { + return [[0, n]]; + } + + const matchedIndices = []; + let i = m; + let j = n; + while (i > 0 && j > 0) { + const cost = q[i - 1] === t[j - 1] ? 0 : 1; + const diag = d[i - 1][j - 1] + cost; + const up = d[i - 1][j] + 1; + const left = d[i][j - 1] + 1; + const min = Math.min(diag, up, left); + + if (min === diag) { + if (cost === 0) matchedIndices.push(j - 1); + i--; + j--; + } else if (min === up) { + i--; + } else { + j--; + } + } + + matchedIndices.sort((a, b) => a - b); + const ranges = []; + for (let k = 0; k < matchedIndices.length; k++) { + if (ranges.length && matchedIndices[k] === ranges[ranges.length - 1][1]) { + ranges[ranges.length - 1][1]++; + } else { + ranges.push([matchedIndices[k], matchedIndices[k] + 1]); + } + } + return ranges; + } + + function highlightFuzzyInText(query, text) { + const ranges = getFuzzyMatchRanges(query, text); + if (!ranges.length) return text; + + let result = ''; + let lastEnd = 0; + for (let i = 0; i < ranges.length; i++) { + const start = ranges[i][0]; + const end = ranges[i][1]; + result += text.slice(lastEnd, start); + result += '' + text.slice(start, end) + ''; + lastEnd = end; + } + result += text.slice(lastEnd); + return result; + } + + Search.highlightMatches = function (searchQuery, els, matchWords) { if (!searchQuery || !els.length) { return; } searchQuery = utils.escapeHTML(searchQuery.replace(/^"/, '').replace(/"$/, '').trim()); - const regexStr = searchQuery.split(' ') - .filter(word => word.length > 1) - .map(function (word) { return utils.escapeRegexChars(word); }) - .join('|'); - const regex = new RegExp('(' + regexStr + ')', 'gi'); + const isFuzzy = matchWords === 'fuzzy'; els.each(function () { const result = $(this); @@ -334,9 +428,33 @@ define('search', [ nested.push($('
').append($(this))); }); - result.html(result.html().replace(regex, function (match, p1) { - return '' + p1 + ''; - })); + let html = result.html(); + if (isFuzzy) { + const queryTokens = searchQuery.split(/\s+/).filter(function (t) { return t.length > 1; }); + if (queryTokens.length) { + html = html.replace(/[a-zA-Z\u00C0-\u024F]+/g, function (word) { + for (let i = 0; i < queryTokens.length; i++) { + const ranges = getFuzzyMatchRanges(queryTokens[i], word); + if (ranges.length > 0) { + return highlightFuzzyInText(queryTokens[i], word); + } + } + return word; + }); + } + } else { + const regexStr = searchQuery.split(' ') + .filter(function (word) { return word.length > 1; }) + .map(function (word) { return utils.escapeRegexChars(word); }) + .join('|'); + if (regexStr) { + const regex = new RegExp('(' + regexStr + ')', 'gi'); + html = html.replace(regex, function (match, p1) { + return '' + p1 + ''; + }); + } + } + result.html(html); nested.forEach(function (nestedEl, i) { result.html(result.html().replace('', function () { diff --git a/src/controllers/search.js b/src/controllers/search.js index 8b21189e7d..7614e247d0 100644 --- a/src/controllers/search.js +++ b/src/controllers/search.js @@ -139,6 +139,7 @@ searchController.search = async function (req, res, next) { searchData.tagFilterSelected = getSelectedTags(data.hasTags); searchData.searchDefaultSortBy = meta.config.searchDefaultSortBy || ''; searchData.searchDefaultIn = meta.config.searchDefaultIn || 'titlesposts'; + searchData.matchWords = data.matchWords || 'all'; searchData.privileges = userPrivileges; res.render('search', searchData); diff --git a/vendor/nodebb-theme-harmony-2.1.35/templates/partials/search-results.tpl b/vendor/nodebb-theme-harmony-2.1.35/templates/partials/search-results.tpl index e007476dde..49fec60540 100644 --- a/vendor/nodebb-theme-harmony-2.1.35/templates/partials/search-results.tpl +++ b/vendor/nodebb-theme-harmony-2.1.35/templates/partials/search-results.tpl @@ -6,7 +6,7 @@ {{{ end }}} {{{ end }}} -
+
{{{ if showAsPosts }}} {{{ if posts.length }}} diff --git a/vendor/nodebb-theme-harmony-main/templates/partials/search-results.tpl b/vendor/nodebb-theme-harmony-main/templates/partials/search-results.tpl index e007476dde..49fec60540 100644 --- a/vendor/nodebb-theme-harmony-main/templates/partials/search-results.tpl +++ b/vendor/nodebb-theme-harmony-main/templates/partials/search-results.tpl @@ -6,7 +6,7 @@ {{{ end }}} {{{ end }}} -
+
{{{ if showAsPosts }}} {{{ if posts.length }}} From 4c4efdbe9e6a80034e32cb29deb94a5d831bc6a3 Mon Sep 17 00:00:00 2001 From: anthony-tom Date: Fri, 27 Feb 2026 16:16:54 -0500 Subject: [PATCH 3/6] Create et --hard 54de545cd5 --- et --hard 54de545cd5 | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 et --hard 54de545cd5 diff --git a/et --hard 54de545cd5 b/et --hard 54de545cd5 new file mode 100644 index 0000000000..e4fcb96b53 --- /dev/null +++ b/et --hard 54de545cd5 @@ -0,0 +1,41 @@ +234b72ec4f (HEAD -> fix-fuzzy-search-highlight, origin/main, origin/HEAD) HEAD@{0}: reset: moving to HEAD~3 +32159e3ff4 (origin/fix-fuzzy-search-highlight) HEAD@{1}: pull --ff --recurse-submodules --progress origin: Merge made by the 'ort' strategy. +54de545cd5 HEAD@{2}: commit: Fix: Dynamic fuzzy search word highlighting +c40e0a7e56 (main) HEAD@{3}: reset: moving to HEAD~1 +b223bd4b08 HEAD@{4}: commit (amend): Fix: Dynamic fuzzy search word highlighting +82cc15c1c4 HEAD@{5}: commit (amend): Fix: Dynamic fuzzy search word highlighting +ee70322766 HEAD@{6}: commit: Fix: Dynamic fuzzy search word highlighting +c40e0a7e56 (main) HEAD@{7}: checkout: moving from main to fix-fuzzy-search-highlight +c40e0a7e56 (main) HEAD@{8}: commit: Update fuzzy.js with redundancy removal +234b72ec4f (HEAD -> fix-fuzzy-search-highlight, origin/main, origin/HEAD) HEAD@{9}: pull --ff --recurse-submodules --progress origin: Fast-forward +a32da0688f HEAD@{10}: checkout: moving from feat/fuzzy-search to main +18d77298b8 (origin/feat/fuzzy-search, feat/fuzzy-search) HEAD@{11}: checkout: moving from main to feat/fuzzy-search +a32da0688f HEAD@{12}: checkout: moving from feat/fuzzy-search to main +18d77298b8 (origin/feat/fuzzy-search, feat/fuzzy-search) HEAD@{13}: checkout: moving from main to feat/fuzzy-search +a32da0688f HEAD@{14}: checkout: moving from feat/fuzzy-search to main +18d77298b8 (origin/feat/fuzzy-search, feat/fuzzy-search) HEAD@{15}: checkout: moving from main to feat/fuzzy-search +a32da0688f HEAD@{16}: pull --ff --recurse-submodules --progress origin: Fast-forward +6e47a28f72 HEAD@{17}: checkout: moving from feat/fuzzy-search to main +18d77298b8 (origin/feat/fuzzy-search, feat/fuzzy-search) HEAD@{18}: commit: CI: use URL without base path to fix 307 in API tests +7d43b4dd10 HEAD@{19}: commit: Test: Update test/api.js +e32c23e883 HEAD@{20}: reset: moving to HEAD~2 +cb08d36d1d HEAD@{21}: commit: test: fix fuzzy search unit tests +17a4c433f1 HEAD@{22}: commit: fix(tests): include relative_path in API test request URLs +e32c23e883 HEAD@{23}: reset: moving to HEAD@{1} +c4899e233a HEAD@{24}: reset: moving to HEAD~1 +e32c23e883 HEAD@{25}: commit: Test: Add unit tests for fuzzy search helpers (levenshtein, fuzzyMatches) +c4899e233a HEAD@{26}: commit: feat: add fuzzy search option with backend integration +6e47a28f72 HEAD@{27}: reset: moving to HEAD~1 +8a4dca9e81 HEAD@{28}: commit: feat: implement fuzzy search using Fuse.js in search page +6e47a28f72 HEAD@{29}: checkout: moving from main to feat/fuzzy-search +6e47a28f72 HEAD@{30}: checkout: moving from search-contains-option to main +7087a94f67 (origin/search-contains-option, search-contains-option) HEAD@{31}: commit: Adds match-contains field to en-GB search.json +0fd6c5ade6 HEAD@{32}: commit: Rename “Contains” dropdown option to “Match contains” +3f94e1fa11 HEAD@{33}: commit: Add “Contains” search mode with substring matching +f61ec6e3b4 HEAD@{34}: checkout: moving from main to search-contains-option +f61ec6e3b4 HEAD@{35}: checkout: moving from feature/search-substring to main +f61ec6e3b4 HEAD@{36}: checkout: moving from main to feature/search-substring +f61ec6e3b4 HEAD@{37}: checkout: moving from port/p1-anthony to main +92b59ffb6a (origin/port/p1-anthony, port/p1-anthony) HEAD@{38}: cherry-pick: Refactor Categories.create: simplify conditionals to reduce complexity +f61ec6e3b4 HEAD@{39}: checkout: moving from main to port/p1-anthony +f61ec6e3b4 HEAD@{40}: clone: from https://github.com/CMU-313/nodebb-spring-26-kernel-panic.git From 18c806cb5863af2582a9df151286b146d4275975 Mon Sep 17 00:00:00 2001 From: anthony-tom Date: Fri, 27 Feb 2026 16:22:45 -0500 Subject: [PATCH 4/6] Fix: remove unused getLevenshteinDistance function --- public/src/modules/search.js | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/public/src/modules/search.js b/public/src/modules/search.js index f43fded297..50c1b3f5e9 100644 --- a/public/src/modules/search.js +++ b/public/src/modules/search.js @@ -314,23 +314,6 @@ define('search', [ } }; - function getLevenshteinDistance(a, b) { - if (a.length === 0) return b.length; - if (b.length === 0) return a.length; - const m = a.length; - const n = b.length; - const d = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0)); - for (let i = 0; i <= m; i++) d[i][0] = i; - for (let j = 0; j <= n; j++) d[0][j] = j; - for (let i = 1; i <= m; i++) { - for (let j = 1; j <= n; j++) { - const cost = a[i - 1] === b[j - 1] ? 0 : 1; - d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost); - } - } - return d[m][n]; - } - function maxFuzzyEdits(len) { if (len <= 5) return 1; if (len <= 9) return 2; From 682723c254ad824a261fb4276fadb68a7bfe1f91 Mon Sep 17 00:00:00 2001 From: anthony-tom Date: Fri, 27 Feb 2026 16:32:43 -0500 Subject: [PATCH 5/6] Update search.yaml - Fixes npm test error with matchWords --- public/openapi/read/search.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/public/openapi/read/search.yaml b/public/openapi/read/search.yaml index 6e36fc3891..c12347aa99 100644 --- a/public/openapi/read/search.yaml +++ b/public/openapi/read/search.yaml @@ -78,6 +78,8 @@ get: type: string class: type: string + matchWords: + type: string searchDefaultSortBy: type: string searchDefaultIn: From 36a91f7c16fc9685d38cd2b81613108ec974960e Mon Sep 17 00:00:00 2001 From: anthony-tom Date: Fri, 27 Feb 2026 17:38:41 -0500 Subject: [PATCH 6/6] Delete et --hard 54de545cd5 --- et --hard 54de545cd5 | 41 ----------------------------------------- 1 file changed, 41 deletions(-) delete mode 100644 et --hard 54de545cd5 diff --git a/et --hard 54de545cd5 b/et --hard 54de545cd5 deleted file mode 100644 index e4fcb96b53..0000000000 --- a/et --hard 54de545cd5 +++ /dev/null @@ -1,41 +0,0 @@ -234b72ec4f (HEAD -> fix-fuzzy-search-highlight, origin/main, origin/HEAD) HEAD@{0}: reset: moving to HEAD~3 -32159e3ff4 (origin/fix-fuzzy-search-highlight) HEAD@{1}: pull --ff --recurse-submodules --progress origin: Merge made by the 'ort' strategy. -54de545cd5 HEAD@{2}: commit: Fix: Dynamic fuzzy search word highlighting -c40e0a7e56 (main) HEAD@{3}: reset: moving to HEAD~1 -b223bd4b08 HEAD@{4}: commit (amend): Fix: Dynamic fuzzy search word highlighting -82cc15c1c4 HEAD@{5}: commit (amend): Fix: Dynamic fuzzy search word highlighting -ee70322766 HEAD@{6}: commit: Fix: Dynamic fuzzy search word highlighting -c40e0a7e56 (main) HEAD@{7}: checkout: moving from main to fix-fuzzy-search-highlight -c40e0a7e56 (main) HEAD@{8}: commit: Update fuzzy.js with redundancy removal -234b72ec4f (HEAD -> fix-fuzzy-search-highlight, origin/main, origin/HEAD) HEAD@{9}: pull --ff --recurse-submodules --progress origin: Fast-forward -a32da0688f HEAD@{10}: checkout: moving from feat/fuzzy-search to main -18d77298b8 (origin/feat/fuzzy-search, feat/fuzzy-search) HEAD@{11}: checkout: moving from main to feat/fuzzy-search -a32da0688f HEAD@{12}: checkout: moving from feat/fuzzy-search to main -18d77298b8 (origin/feat/fuzzy-search, feat/fuzzy-search) HEAD@{13}: checkout: moving from main to feat/fuzzy-search -a32da0688f HEAD@{14}: checkout: moving from feat/fuzzy-search to main -18d77298b8 (origin/feat/fuzzy-search, feat/fuzzy-search) HEAD@{15}: checkout: moving from main to feat/fuzzy-search -a32da0688f HEAD@{16}: pull --ff --recurse-submodules --progress origin: Fast-forward -6e47a28f72 HEAD@{17}: checkout: moving from feat/fuzzy-search to main -18d77298b8 (origin/feat/fuzzy-search, feat/fuzzy-search) HEAD@{18}: commit: CI: use URL without base path to fix 307 in API tests -7d43b4dd10 HEAD@{19}: commit: Test: Update test/api.js -e32c23e883 HEAD@{20}: reset: moving to HEAD~2 -cb08d36d1d HEAD@{21}: commit: test: fix fuzzy search unit tests -17a4c433f1 HEAD@{22}: commit: fix(tests): include relative_path in API test request URLs -e32c23e883 HEAD@{23}: reset: moving to HEAD@{1} -c4899e233a HEAD@{24}: reset: moving to HEAD~1 -e32c23e883 HEAD@{25}: commit: Test: Add unit tests for fuzzy search helpers (levenshtein, fuzzyMatches) -c4899e233a HEAD@{26}: commit: feat: add fuzzy search option with backend integration -6e47a28f72 HEAD@{27}: reset: moving to HEAD~1 -8a4dca9e81 HEAD@{28}: commit: feat: implement fuzzy search using Fuse.js in search page -6e47a28f72 HEAD@{29}: checkout: moving from main to feat/fuzzy-search -6e47a28f72 HEAD@{30}: checkout: moving from search-contains-option to main -7087a94f67 (origin/search-contains-option, search-contains-option) HEAD@{31}: commit: Adds match-contains field to en-GB search.json -0fd6c5ade6 HEAD@{32}: commit: Rename “Contains” dropdown option to “Match contains” -3f94e1fa11 HEAD@{33}: commit: Add “Contains” search mode with substring matching -f61ec6e3b4 HEAD@{34}: checkout: moving from main to search-contains-option -f61ec6e3b4 HEAD@{35}: checkout: moving from feature/search-substring to main -f61ec6e3b4 HEAD@{36}: checkout: moving from main to feature/search-substring -f61ec6e3b4 HEAD@{37}: checkout: moving from port/p1-anthony to main -92b59ffb6a (origin/port/p1-anthony, port/p1-anthony) HEAD@{38}: cherry-pick: Refactor Categories.create: simplify conditionals to reduce complexity -f61ec6e3b4 HEAD@{39}: checkout: moving from main to port/p1-anthony -f61ec6e3b4 HEAD@{40}: clone: from https://github.com/CMU-313/nodebb-spring-26-kernel-panic.git