Exact page matches in search
This commit is contained in:
@@ -11,9 +11,58 @@ function rebuildIndex() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function encodeSearchPath(p) {
|
||||||
|
if (p === '/' || p === '') return '/';
|
||||||
|
return '/' + p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// createSearchPage runs the new-page flow (pick a parent folder, then confirm
|
||||||
|
// the name) starting from the search query, so a query with no exact page can
|
||||||
|
// be turned into that page in one step. The name field is pre-filled with the
|
||||||
|
// query but stays editable.
|
||||||
|
function createSearchPage(name) {
|
||||||
|
var current = decodeURIComponent(window.location.pathname).replace(/\/+$/, '') || '/';
|
||||||
|
openTreePicker({
|
||||||
|
title: 'New page — where?',
|
||||||
|
mode: 'folder',
|
||||||
|
initialPath: current,
|
||||||
|
preselect: current,
|
||||||
|
hideFiles: true,
|
||||||
|
confirmLabel: 'NEXT',
|
||||||
|
onSelect: function (parentPath) {
|
||||||
|
var input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.className = 'input';
|
||||||
|
input.placeholder = 'Page name';
|
||||||
|
input.value = name || '';
|
||||||
|
openModal({
|
||||||
|
title: 'New page — name?',
|
||||||
|
body: input,
|
||||||
|
confirm: {
|
||||||
|
label: 'CREATE',
|
||||||
|
onConfirm: function () {
|
||||||
|
var finalName = input.value.trim();
|
||||||
|
if (!finalName) return;
|
||||||
|
var base = parentPath === '/' ? '/' : encodeSearchPath(parentPath) + '/';
|
||||||
|
window.location.href = base + encodeURIComponent(finalName) + '/?edit';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
wireDropdown(document.querySelector('[data-action="actions-drop"]'));
|
wireDropdown(document.querySelector('[data-action="actions-drop"]'));
|
||||||
|
|
||||||
|
var createLink = document.querySelector('[data-create-page]');
|
||||||
|
if (createLink) {
|
||||||
|
createLink.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
createSearchPage(createLink.getAttribute('data-create-page'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Focus the search input on results pages so Tab steps directly into the
|
// Focus the search input on results pages so Tab steps directly into the
|
||||||
// first match — the input sits immediately before the results in DOM
|
// first match — the input sits immediately before the results in DOM
|
||||||
// order, so the natural tab sequence is input → first result → next, …
|
// order, so the natural tab sequence is input → first result → next, …
|
||||||
|
|||||||
+11
-4
@@ -4,7 +4,17 @@
|
|||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
{{if .Query}}
|
{{if .Query}}
|
||||||
{{if or .Pages .Files}}
|
{{if .Exact}}
|
||||||
|
<h2 class="search-section">Exact Match</h2>
|
||||||
|
{{range .Exact}}
|
||||||
|
<article class="search-card">
|
||||||
|
<a href="{{.URL}}">{{.Name}}</a>
|
||||||
|
<div class="muted">/{{.Path}}</div>
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<p class="muted">No page named “{{.Query}}” — <button class="btn btn-small" data-create-page="{{.Query}}">create it</button></p>
|
||||||
|
{{end}}
|
||||||
{{if .Pages}}
|
{{if .Pages}}
|
||||||
<h2 class="search-section">Matching Pages <span class="muted">{{.PageTotal}}</span></h2>
|
<h2 class="search-section">Matching Pages <span class="muted">{{.PageTotal}}</span></h2>
|
||||||
{{range .Pages}}
|
{{range .Pages}}
|
||||||
@@ -27,9 +37,6 @@
|
|||||||
</article>
|
</article>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
{{else}}
|
|
||||||
<p class="empty">No matches for “{{.Query}}”.</p>
|
|
||||||
{{end}}
|
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="empty">Enter a query above.</p>
|
<p class="empty">Enter a query above.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ import (
|
|||||||
// section header still reports the true total so the user knows more exist.
|
// section header still reports the true total so the user knows more exist.
|
||||||
const searchSectionCap = 10
|
const searchSectionCap = 10
|
||||||
|
|
||||||
|
// exactNameScore is the score scoreName assigns to a whole-name exact match.
|
||||||
|
// handleSearch peels these into the promoted "Exact Match" section.
|
||||||
|
const exactNameScore = 1000
|
||||||
|
|
||||||
type searchResult struct {
|
type searchResult struct {
|
||||||
Name string
|
Name string
|
||||||
URL string
|
URL string
|
||||||
@@ -32,6 +36,9 @@ type searchPageData struct {
|
|||||||
Title string
|
Title string
|
||||||
EditMode bool
|
EditMode bool
|
||||||
Query string
|
Query string
|
||||||
|
// Exact holds page(s) whose name equals the query exactly, promoted to
|
||||||
|
// their own section above the fuzzy matches.
|
||||||
|
Exact []searchResult
|
||||||
Pages []searchResult
|
Pages []searchResult
|
||||||
Files []searchResult
|
Files []searchResult
|
||||||
// PageTotal/FileTotal are the true match totals; Pages/Files are capped at
|
// PageTotal/FileTotal are the true match totals; Pages/Files are capped at
|
||||||
@@ -101,6 +108,15 @@ func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
pages, builtAt := searchWiki(query)
|
pages, builtAt := searchWiki(query)
|
||||||
files := searchFiles(query)
|
files := searchFiles(query)
|
||||||
|
|
||||||
|
// searchWiki sorts by score desc, so exact-name matches (score 1000) are
|
||||||
|
// at the front; peel them into their own section and drop them from the
|
||||||
|
// fuzzy page list so they aren't shown twice.
|
||||||
|
var exact []searchResult
|
||||||
|
for len(pages) > 0 && pages[0].Score == exactNameScore {
|
||||||
|
exact = append(exact, pages[0])
|
||||||
|
pages = pages[1:]
|
||||||
|
}
|
||||||
|
|
||||||
title := "Search"
|
title := "Search"
|
||||||
if query != "" {
|
if query != "" {
|
||||||
title = "Search: " + query
|
title = "Search: " + query
|
||||||
@@ -108,6 +124,7 @@ func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
data := searchPageData{
|
data := searchPageData{
|
||||||
Title: title,
|
Title: title,
|
||||||
Query: query,
|
Query: query,
|
||||||
|
Exact: exact,
|
||||||
Pages: capResults(pages),
|
Pages: capResults(pages),
|
||||||
Files: capResults(files),
|
Files: capResults(files),
|
||||||
PageTotal: len(pages),
|
PageTotal: len(pages),
|
||||||
@@ -242,7 +259,7 @@ func fileURL(relPath string) string {
|
|||||||
// large file volumes don't pay the edit-distance cost per query.
|
// large file volumes don't pay the edit-distance cost per query.
|
||||||
func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []string, fuzzy bool) int {
|
func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []string, fuzzy bool) int {
|
||||||
if nameLower == qLower {
|
if nameLower == qLower {
|
||||||
return 1000
|
return exactNameScore
|
||||||
}
|
}
|
||||||
score := 0
|
score := 0
|
||||||
for _, qt := range qTokens {
|
for _, qt := range qTokens {
|
||||||
|
|||||||
Reference in New Issue
Block a user