Compare commits

...

5 Commits

Author SHA1 Message Date
luxick 86f2b7c34f Refactor Layout and improve search 2026-04-29 19:26:01 +02:00
luxick c688761e89 Full-text search v1 2026-04-29 14:30:37 +02:00
luxick 174e2dd1cd exclude .zed dir 2026-04-29 14:18:27 +02:00
luxick a9ca40c2bd Resolve symlink root in search 2026-04-29 14:17:52 +02:00
luxick eae5d1cc25 Search phase 1 2026-04-29 14:12:18 +02:00
9 changed files with 556 additions and 112 deletions
+1
View File
@@ -1,4 +1,5 @@
.claude/ .claude/
.zed/
wiki/ wiki/
cache/ cache/
+39
View File
@@ -0,0 +1,39 @@
{{define "headerActions"}}
<a class="btn" href="{{.PostURL}}">CANCEL</a>
<button class="btn" type="submit" form="edit-form" data-action="save" data-key="S" title="Save (S)">SAVE</button>
{{end}}
{{define "content"}}
<form id="edit-form" class="edit-form" method="POST" action="{{.PostURL}}">
{{if ge .SectionIndex 0}}<input type="hidden" name="section" value="{{.SectionIndex}}">{{end}}
<div class="editor-toolbar">
<button type="button" class="btn btn-tool" data-action="bold" data-key="B" title="Bold (B)">**</button>
<button type="button" class="btn btn-tool" data-action="italic" data-key="I" title="Italic (I)">*</button>
<span class="toolbar-sep"></span>
<button type="button" class="btn btn-tool" data-action="h1" data-key="1" title="Heading 1 (1)">#</button>
<button type="button" class="btn btn-tool" data-action="h2" data-key="2" title="Heading 2 (2)">##</button>
<button type="button" class="btn btn-tool" data-action="h3" data-key="3" title="Heading 3 (3)">###</button>
<span class="toolbar-sep"></span>
<button type="button" class="btn btn-tool" data-action="code" data-key="C" title="Inline code (C)">`</button>
<button type="button" class="btn btn-tool" data-action="codeblock" data-key="K" title="Code block (K)">```</button>
<span class="toolbar-sep"></span>
<button type="button" class="btn btn-tool" data-action="link" data-key="L" title="Link (L)">[]</button>
<button type="button" class="btn btn-tool" data-action="wikilink" data-key="P" title="Insert wiki link (P)">[[]]</button>
<button type="button" class="btn btn-tool" data-action="quote" data-key="Q" title="Blockquote (Q)">&gt;</button>
<button type="button" class="btn btn-tool" data-action="ul" data-key="U" title="Unordered list (U)">-</button>
<button type="button" class="btn btn-tool" data-action="ol" data-key="O" title="Ordered list (O)">1.</button>
<button type="button" class="btn btn-tool" data-action="hr" data-key="R" title="Horizontal rule (R)">---</button>
<span class="toolbar-sep"></span>
<button type="button" class="btn btn-tool dropdown" data-action="tbldrop" title="Table (T)">T▾</button>
<button type="button" class="btn btn-tool dropdown" data-action="datedrop" title="Insert date (D/W)">D▾</button>
<span class="toolbar-sep"></span>
<button type="button" class="btn btn-tool" data-action="movie" data-key="V" title="Import movie (V)">MV</button>
</div>
<textarea name="content" id="editor" autofocus>{{.RawContent}}</textarea>
</form>
<script src="/_/editor/lists.js"></script>
<script src="/_/editor/tables.js"></script>
<script src="/_/editor/dates.js"></script>
<script src="/_/editor/movie.js"></script>
<script src="/_/editor.js"></script>
{{end}}
+7
View File
@@ -14,6 +14,13 @@
e.preventDefault(); e.preventDefault();
if (window.location.pathname !== '/' && typeof movePage === 'function') movePage(); if (window.location.pathname !== '/' && typeof movePage === 'function') movePage();
break; break;
case 'F':
var input = document.querySelector('.search-input');
if (!input) return;
e.preventDefault();
input.focus();
input.select();
break;
} }
}); });
})(); })();
+37
View File
@@ -0,0 +1,37 @@
{{define "layout"}}<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{.Title}}</title>
<link rel="icon" href="/_/favicon.ico" />
<link rel="preload" href="/_/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/_/fonts/IosevkaSlab.woff2" as="font" type="font/woff2" crossorigin />
<link rel="stylesheet" href="/_/style.css" />
<script src="/_/modal.js"></script>
<script src="/_/global-shortcuts.js"></script>
<script src="/_/tree-picker.js"></script>
{{block "headScripts" .}}{{end}}
</head>
<body>
<header>
<nav class="breadcrumb">
<a href="/"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg></a>
{{range .Crumbs}}
<span class="sep">/</span><a href="{{.URL}}">{{.Name}}</a>
{{end}}
</nav>
{{if not .EditMode}}
<form class="search-form" action="/" method="get">
<input class="search-input" type="search" name="q" value="{{block "searchQuery" .}}{{end}}" placeholder="Search…" title="Search (F)" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
</form>
{{end}}
{{block "headerActions" .}}{{end}}
</header>
<main>
{{block "content" .}}{{end}}
</main>
{{block "extras" .}}{{end}}
</body>
</html>
{{end}}
+35 -93
View File
@@ -1,31 +1,8 @@
<!doctype html> {{define "headScripts"}}<script src="/_/page-actions.js"></script>{{end}}
<html lang="en">
<head> {{define "headerActions"}}
<meta charset="UTF-8" /> {{if .CanEdit}}
<meta name="viewport" content="width=device-width, initial-scale=1" /> <div class="dropdown">
<title>{{.Title}}</title>
<link rel="icon" href="/_/favicon.ico" />
<link rel="preload" href="/_/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/_/fonts/IosevkaSlab.woff2" as="font" type="font/woff2" crossorigin />
<link rel="stylesheet" href="/_/style.css" />
<script src="/_/modal.js"></script>
<script src="/_/global-shortcuts.js"></script>
<script src="/_/tree-picker.js"></script>
<script src="/_/page-actions.js"></script>
</head>
<body>
<header>
<nav class="breadcrumb">
<a href="/"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg></a>
{{range .Crumbs}}
<span class="sep">/</span><a href="{{.URL}}">{{.Name}}</a>
{{end}}
</nav>
{{if .EditMode}}
<a class="btn" href="{{.PostURL}}">CANCEL</a>
<button class="btn" type="submit" form="edit-form" data-action="save" data-key="S" title="Save (S)">SAVE</button>
{{else if .CanEdit}}
<div class="dropdown">
<button class="btn" data-action="actions-drop" title="Actions">ACTIONS ▾</button> <button class="btn" data-action="actions-drop" title="Actions">ACTIONS ▾</button>
<div class="dropdown-menu align-right"> <div class="dropdown-menu align-right">
<button class="btn dropdown-item" onclick="newPage()" title="New page (N)">NEW</button> <button class="btn dropdown-item" onclick="newPage()" title="New page (N)">NEW</button>
@@ -35,60 +12,27 @@
<button class="btn dropdown-item danger" onclick="deletePage()" title="Delete page">DELETE</button> <button class="btn dropdown-item danger" onclick="deletePage()" title="Delete page">DELETE</button>
{{end}} {{end}}
</div> </div>
</div> </div>
{{end}} {{end}}
</header> {{end}}
<main>
{{if .EditMode}} {{define "content"}}
<form id="edit-form" class="edit-form" method="POST" action="{{.PostURL}}"> {{if .Content}}
{{if ge .SectionIndex 0}}<input type="hidden" name="section" value="{{.SectionIndex}}">{{end}} <div class="content">{{.Content}}</div>
<div class="editor-toolbar"> {{end}}
<button type="button" class="btn btn-tool" data-action="bold" data-key="B" title="Bold (B)">**</button> {{if .SpecialContent}}
<button type="button" class="btn btn-tool" data-action="italic" data-key="I" title="Italic (I)">*</button> <div class="content">{{.SpecialContent}}</div>
<span class="toolbar-sep"></span> {{end}}
<button type="button" class="btn btn-tool" data-action="h1" data-key="1" title="Heading 1 (1)">#</button> {{if or .Content .SpecialContent}}
<button type="button" class="btn btn-tool" data-action="h2" data-key="2" title="Heading 2 (2)">##</button> <script src="/_/content.js"></script>
<button type="button" class="btn btn-tool" data-action="h3" data-key="3" title="Heading 3 (3)">###</button> <script src="/_/anchors.js"></script>
<span class="toolbar-sep"></span> <script src="/_/toc.js"></script>
<button type="button" class="btn btn-tool" data-action="code" data-key="C" title="Inline code (C)">`</button> {{end}}
<button type="button" class="btn btn-tool" data-action="codeblock" data-key="K" title="Code block (K)">```</button> {{if .Content}}
<span class="toolbar-sep"></span> <script src="/_/sections.js"></script>
<button type="button" class="btn btn-tool" data-action="link" data-key="L" title="Link (L)">[]</button> {{end}}
<button type="button" class="btn btn-tool" data-action="wikilink" data-key="P" title="Insert wiki link (P)">[[]]</button> {{if .Entries}}
<button type="button" class="btn btn-tool" data-action="quote" data-key="Q" title="Blockquote (Q)">&gt;</button> <div class="listing">
<button type="button" class="btn btn-tool" data-action="ul" data-key="U" title="Unordered list (U)">-</button>
<button type="button" class="btn btn-tool" data-action="ol" data-key="O" title="Ordered list (O)">1.</button>
<button type="button" class="btn btn-tool" data-action="hr" data-key="R" title="Horizontal rule (R)">---</button>
<span class="toolbar-sep"></span>
<button type="button" class="btn btn-tool dropdown" data-action="tbldrop" title="Table (T)">T▾</button>
<button type="button" class="btn btn-tool dropdown" data-action="datedrop" title="Insert date (D/W)">D▾</button>
<span class="toolbar-sep"></span>
<button type="button" class="btn btn-tool" data-action="movie" data-key="V" title="Import movie (V)">MV</button>
</div>
<textarea name="content" id="editor" autofocus>{{.RawContent}}</textarea>
</form>
<script src="/_/editor/lists.js"></script>
<script src="/_/editor/tables.js"></script>
<script src="/_/editor/dates.js"></script>
<script src="/_/editor/movie.js"></script>
<script src="/_/editor.js"></script>
{{else}}
{{if .Content}}
<div class="content">{{.Content}}</div>
{{end}}
{{if .SpecialContent}}
<div class="content">{{.SpecialContent}}</div>
{{end}}
{{if or .Content .SpecialContent}}
<script src="/_/content.js"></script>
<script src="/_/anchors.js"></script>
<script src="/_/toc.js"></script>
{{end}}
{{if .Content}}
<script src="/_/sections.js"></script>
{{end}}
{{if .Entries}}
<div class="listing">
<div class="listing-header">Contents</div> <div class="listing-header">Contents</div>
{{range .Entries}} {{range .Entries}}
<div class="listing-item"> <div class="listing-item">
@@ -97,14 +41,12 @@
<span class="meta">{{.Meta}}</span> <span class="meta">{{.Meta}}</span>
</div> </div>
{{end}} {{end}}
</div> </div>
{{else if not .Content}} {{else if not .Content}}
{{if not .SpecialContent}} {{if not .SpecialContent}}
<p class="empty">Empty folder — <a href="?edit">[CREATE]</a></p> <p class="empty">Empty folder — <a href="?edit">[CREATE]</a></p>
{{end}} {{end}}
{{end}} {{end}}
{{end}} {{end}}
</main>
{{if .SidebarWidget}}{{.SidebarWidget}}{{end}} {{define "extras"}}{{if .SidebarWidget}}{{.SidebarWidget}}{{end}}{{end}}
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
{{define "searchQuery"}}{{.Query}}{{end}}
{{define "content"}}
{{if .Query}}
{{if .Results}}
<h2 class="muted search-summary">{{len .Results}} match{{if ne (len .Results) 1}}es{{end}} for &ldquo;{{.Query}}&rdquo;</h2>
<div class="search-results">
{{range .Results}}
<article class="search-card">
<a class="search-card-name" href="{{.URL}}">{{.Name}}</a>
<div class="search-card-path muted">/{{.Path}}</div>
{{if .Snippet}}<div class="search-card-snippet">{{.Snippet}}</div>{{end}}
</article>
{{end}}
</div>
{{else}}
<p class="empty">No matches for &ldquo;{{.Query}}&rdquo;.</p>
{{end}}
{{else}}
<p class="empty">Enter a query above.</p>
{{end}}
{{end}}
+56
View File
@@ -359,6 +359,62 @@ textarea {
box-sizing: border-box; box-sizing: border-box;
} }
/* === Search === */
.search-form {
display: flex;
gap: 0.25rem;
}
.search-input {
background: var(--bg-panel);
border: 1px solid var(--secondary);
color: var(--text);
font: inherit;
font-size: 0.9rem;
padding: 0.3rem 0.5rem;
min-width: 0;
width: 12rem;
max-width: 100%;
outline: none;
}
.search-input:focus {
border-color: var(--primary-hover);
}
.search-summary {
margin-bottom: 1rem;
}
.search-results {
display: flex;
flex-direction: column;
gap: 1rem;
}
.search-card {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding-bottom: 1rem;
border-bottom: 1px dashed var(--secondary);
}
.search-card:last-child {
border-bottom: none;
}
.search-card-name {
color: var(--link);
font-size: 1.1rem;
word-break: break-word;
}
.search-card-name:hover {
color: var(--link-hover);
}
.search-card-path {
word-break: break-all;
}
.search-card-snippet {
font-size: 0.9rem;
line-height: 1.5;
color: var(--text-muted);
margin-top: 0.25rem;
}
/* === Muted text === */ /* === Muted text === */
.muted { .muted {
color: var(--text-muted); color: var(--text-muted);
+15 -2
View File
@@ -17,7 +17,11 @@ import (
//go:embed assets //go:embed assets
var assets embed.FS var assets embed.FS
var tmpl = template.Must(template.New("page.html").ParseFS(assets, "assets/page.html")) var (
pageTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/page.html"))
editTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/edit.html"))
searchTmpl = template.Must(template.ParseFS(assets, "assets/layout.html", "assets/search.html"))
)
// specialPage is the result returned by a pageTypeHandler. // specialPage is the result returned by a pageTypeHandler.
// Content is injected into the page after the standard markdown content. // Content is injected into the page after the standard markdown content.
@@ -115,6 +119,11 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return return
} }
if r.Method == http.MethodGet && urlPath == "/" && r.URL.Query().Has("q") {
h.handleSearch(w, r)
return
}
info, err := os.Stat(fsPath) info, err := os.Stat(fsPath)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
@@ -221,7 +230,11 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil { t := pageTmpl
if editMode {
t = editTmpl
}
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
log.Printf("template error: %v", err) log.Printf("template error: %v", err)
} }
} }
+327
View File
@@ -0,0 +1,327 @@
package main
import (
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"unicode"
)
type searchResult struct {
Name string
URL string
Path string
Score int // number of query tokens that hit
NameHit bool // at least one hit came from the folder name
Snippet string // ~300 chars around first body hit, or page stub for name-only hits
}
type searchPageData struct {
Title string
Crumbs []crumb
EditMode bool
Query string
Results []searchResult
}
// handleSearch walks the wiki root and renders a search results page for the
// query in r.URL.Query().Get("q"). Only invoked when path is "/" and "q" is
// present.
func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
query := strings.TrimSpace(r.URL.Query().Get("q"))
results := searchWiki(h.root, query)
title := "Search"
if query != "" {
title = "Search: " + query
}
data := searchPageData{
Title: title,
Crumbs: []crumb{{Name: "search", URL: "/?q=" + query}},
Query: query,
Results: results,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := searchTmpl.ExecuteTemplate(w, "layout", data); err != nil {
log.Printf("search template error: %v", err)
}
}
// searchWiki walks root and scores each directory by how many whitespace-split
// query tokens hit a word in either the folder name or its index.md body.
// A word "hits" a token via case-insensitive equality or Levenshtein ≤ 2.
// Folder-name hits break score ties above content-only hits.
func searchWiki(root, query string) []searchResult {
if query == "" {
return nil
}
qTokens := tokenize(query)
if len(qTokens) == 0 {
return nil
}
walkRoot := resolveWalkRoot(root)
var results []searchResult
_ = filepath.WalkDir(walkRoot, func(fsPath string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if skip, walkErr := hiddenSkip(fsPath, walkRoot, d); skip {
return walkErr
}
if !d.IsDir() || fsPath == walkRoot {
return nil
}
name := d.Name()
body, _ := os.ReadFile(filepath.Join(fsPath, "index.md"))
nameWords := tokenize(name)
bodyStr := string(body)
bodyLower := strings.ToLower(bodyStr)
bodyWords := tokenize(bodyLower)
score := 0
nameHit := false
for _, qt := range qTokens {
inName := tokenInWords(qt, nameWords)
inBody := tokenInWords(qt, bodyWords)
if inName || inBody {
score++
}
if inName {
nameHit = true
}
}
if score == 0 {
return nil
}
rel, relErr := filepath.Rel(walkRoot, fsPath)
if relErr != nil {
return nil
}
results = append(results, searchResult{
Name: name,
URL: "/" + filepath.ToSlash(rel) + "/",
Path: filepath.ToSlash(rel),
Score: score,
NameHit: nameHit,
Snippet: makeSnippet(bodyStr, bodyLower, qTokens),
})
return nil
})
sort.SliceStable(results, func(i, j int) bool {
if results[i].Score != results[j].Score {
return results[i].Score > results[j].Score
}
if results[i].NameHit != results[j].NameHit {
return results[i].NameHit
}
return strings.ToLower(results[i].Name) < strings.ToLower(results[j].Name)
})
return results
}
// resolveWalkRoot resolves symlinks so WalkDir descends into the real tree
// even when the configured wiki root is itself a symlink (as on the NAS).
func resolveWalkRoot(root string) string {
if r, err := filepath.EvalSymlinks(root); err == nil {
return r
}
return root
}
// hiddenSkip handles dotfile/dot-dir entries during a WalkDir. It returns
// (skipped, walkErr): skipped=true means the caller should `return walkErr`
// to either prune the subtree (hidden dir) or move past the entry (hidden
// file). When skipped=false the entry should be processed normally.
func hiddenSkip(fsPath, walkRoot string, d fs.DirEntry) (bool, error) {
if !strings.HasPrefix(d.Name(), ".") {
return false, nil
}
if d.IsDir() && fsPath != walkRoot {
return true, filepath.SkipDir
}
return true, nil
}
// tokenize splits s into lowercase word tokens, breaking on any rune that is
// not a letter or digit. Unicode-aware so umlauts etc. survive intact.
func tokenize(s string) []string {
var tokens []string
var b strings.Builder
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(unicode.ToLower(r))
continue
}
if b.Len() > 0 {
tokens = append(tokens, b.String())
b.Reset()
}
}
if b.Len() > 0 {
tokens = append(tokens, b.String())
}
return tokens
}
// tokenInWords reports whether qt matches any word exactly or within
// Levenshtein distance 2. qt and words must already be lowercase.
func tokenInWords(qt string, words []string) bool {
for _, w := range words {
if w == qt {
return true
}
if levenshtein(w, qt) <= 2 {
return true
}
}
return false
}
var snippetWS = regexp.MustCompile(`\s+`)
const snippetWindow = 300
// makeSnippet returns ~300 characters of body around the earliest substring
// match of any query token. When no token has an exact substring span (e.g.
// matched only via Levenshtein, or the hit was folder-name-only), it falls
// back to the first ~300 chars of the body with the leading heading stripped.
// Returns "" only when the body itself is empty.
func makeSnippet(body, bodyLower string, tokens []string) string {
pos := -1
for _, t := range tokens {
i := strings.Index(bodyLower, t)
if i < 0 {
continue
}
if pos < 0 || i < pos {
pos = i
}
}
if pos < 0 {
return makeStub(body)
}
half := snippetWindow / 2
start := pos - half
if start < 0 {
start = 0
}
end := pos + half
if end > len(body) {
end = len(body)
}
start, end = expandToWordBoundaries(body, start, end)
out := snippetWS.ReplaceAllString(body[start:end], " ")
out = strings.TrimSpace(out)
if start > 0 {
out = "…" + out
}
if end < len(body) {
out = out + "…"
}
return out
}
// makeStub returns ~snippetWindow chars from the start of body, with the
// leading "# Heading" line stripped. Returns "" for an empty body.
func makeStub(body string) string {
stripped := string(stripFirstHeading([]byte(body)))
stripped = strings.TrimSpace(stripped)
if stripped == "" {
return ""
}
end := snippetWindow
if end > len(stripped) {
end = len(stripped)
}
_, end = expandToWordBoundaries(stripped, 0, end)
out := snippetWS.ReplaceAllString(stripped[:end], " ")
out = strings.TrimSpace(out)
if end < len(stripped) {
out = out + "…"
}
return out
}
// expandToWordBoundaries adjusts start/end so they don't split a word and
// don't fall in the middle of a UTF-8 sequence. start moves forward past
// any partial word at the beginning; end moves backward to the previous
// word boundary.
func expandToWordBoundaries(s string, start, end int) (int, int) {
for start > 0 && start < len(s) && s[start]&0xC0 == 0x80 {
start--
}
for end < len(s) && s[end]&0xC0 == 0x80 {
end++
}
if start > 0 && start < len(s) && isWordByte(s[start-1]) && isWordByte(s[start]) {
for start < end && isWordByte(s[start]) {
start++
}
}
if end < len(s) && isWordByte(s[end-1]) && isWordByte(s[end]) {
for end > start && isWordByte(s[end-1]) {
end--
}
}
return start, end
}
func isWordByte(b byte) bool {
if b&0x80 != 0 {
return true // assume any multibyte char is part of a word
}
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
}
// levenshtein returns the edit distance between a and b. Operates on runes so
// multi-byte characters count as one edit.
func levenshtein(a, b string) int {
ar, br := []rune(a), []rune(b)
if len(ar) == 0 {
return len(br)
}
if len(br) == 0 {
return len(ar)
}
prev := make([]int, len(br)+1)
curr := make([]int, len(br)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ar); i++ {
curr[0] = i
for j := 1; j <= len(br); j++ {
cost := 1
if ar[i-1] == br[j-1] {
cost = 0
}
del := prev[j] + 1
ins := curr[j-1] + 1
sub := prev[j-1] + cost
curr[j] = min3(del, ins, sub)
}
prev, curr = curr, prev
}
return prev[len(br)]
}
func min3(a, b, c int) int {
m := a
if b < m {
m = b
}
if c < m {
m = c
}
return m
}