commit fea6da8272de9d85a7b5caa977fd32c1837309a0
parent c65210f8a563cab8046bfa7b6e8d08e681317e08
Author: David Eisinger <[email protected]>
Date: Tue, 7 Oct 2025 01:12:37 -0400
Fix search highlighting bug
Diffstat:
3 files changed, 44 insertions(+), 11 deletions(-)
diff --git a/internal/database.go b/internal/database.go
@@ -12,6 +12,7 @@ import (
"sort"
"strings"
"time"
+ "unicode/utf8"
_ "modernc.org/sqlite"
)
@@ -292,7 +293,7 @@ func (db *DB) GetBookmarkByURL(ctx context.Context, userID int64, u string) (*Bo
return &b, nil
}
-// ListBookmarks returns bookmarks ordered by created_at desc.
+// ListBookmarks returns bookmarks ordered by updated_at desc.
func (db *DB) ListBookmarks(ctx context.Context, userID int64, limit, offset int) ([]Bookmark, error) {
if limit <= 0 {
limit = 50
@@ -301,7 +302,7 @@ func (db *DB) ListBookmarks(ctx context.Context, userID int64, limit, offset int
SELECT id, url, title, description, public, user_id, created_at, updated_at
FROM bookmarks
WHERE user_id = ?
- ORDER BY created_at DESC
+ ORDER BY updated_at DESC
LIMIT ? OFFSET ?
`, userID, limit, offset)
if err != nil {
@@ -674,6 +675,23 @@ func makeExcerpt(content, query string) string {
if end > len(c) {
end = len(c)
}
+ // Align start/end to UTF-8 rune boundaries to avoid broken characters.
+ // Move start backward to the beginning of a rune.
+ for start > 0 {
+ b := c[start]
+ if (b & 0xC0) != 0x80 { // not a continuation byte
+ break
+ }
+ start--
+ }
+ // Move end forward to the end of the current rune if we cut in the middle.
+ for end < len(c) {
+ b := c[end]
+ if (b & 0xC0) != 0x80 { // at a rune boundary already
+ break
+ }
+ end++
+ }
s := c[start:end]
if start > 0 {
s = "…" + s
@@ -681,11 +699,13 @@ func makeExcerpt(content, query string) string {
if end < len(c) {
s = s + "…"
}
- // Highlight all tokens (basic, case-insensitive) using guillemets « » to avoid
- // conflict with brackets used elsewhere.
+ // Highlight all tokens (basic, case-insensitive) using guillemets « ».
+ // Iterate by runes to avoid breaking multi-byte characters while matching
+ // tokens by byte-slices at rune boundaries.
ls := strings.ToLower(s)
var b strings.Builder
for i := 0; i < len(s); {
+ // Try to match any token starting at this byte position
matched := false
for _, t := range fields {
if t == "" {
@@ -700,10 +720,18 @@ func makeExcerpt(content, query string) string {
break
}
}
- if !matched {
+ if matched {
+ continue
+ }
+ r, size := utf8.DecodeRuneInString(s[i:])
+ if r == utf8.RuneError && size == 1 {
+ // Write raw byte on decode error to avoid infinite loop
b.WriteByte(s[i])
i++
+ continue
}
+ b.WriteRune(r)
+ i += size
}
return b.String()
}
diff --git a/internal/user_db.go b/internal/user_db.go
@@ -100,7 +100,7 @@ func (db *DB) ListPublicBookmarks(ctx context.Context, userID int64, limit, offs
rows, err := db.sql.QueryContext(ctx, `
SELECT id, url, title, description, public, user_id, created_at, updated_at
FROM bookmarks WHERE user_id = ? AND public = 1
- ORDER BY created_at DESC
+ ORDER BY updated_at DESC
LIMIT ? OFFSET ?
`, userID, limit, offset)
if err != nil {
diff --git a/internal/web/index.html b/internal/web/index.html
@@ -133,8 +133,10 @@
</div>
</div>
<p v-else-if="b.excerpt" class="excerpt" v-html="formatExcerpt(b.excerpt)"></p>
- <p class="meta">Updated: {{ formatDate(b.updated_at) }}</p>
+ <div v-if="!b.editing && !b.excerpt && b.description" class="desc" v-html="renderMarkdown(b.description)"></div>
<p class="action-links">
+ <span class="meta">{{ formatDate(b.updated_at) }}</span>
+ <span class="sep">|</span>
<template v-if="b.has_archive">
<a :href="`/bookmarks/${b.id}/archive`" target="_blank" title="View archived text">Archive</a>
<span class="sep">|</span>
@@ -143,7 +145,7 @@
<span class="sep">|</span>
<a href="#" @click.prevent="confirmDelete(b)">Delete</a>
</p>
- <div v-if="!b.editing && !b.excerpt && b.description" class="desc" v-html="renderMarkdown(b.description)"></div>
+
</article>
</div>
</section>
@@ -161,11 +163,14 @@
<article v-for="b in results" :key="b.id" class="card">
<h3><a :href="b.url" target="_blank" rel="noopener noreferrer">{{ b.title || b.url }}</a></h3>
<p class="url">{{ b.url }}</p>
- <p class="meta">Updated: {{ formatDate(b.updated_at) }}</p>
+ <div v-if="b.description" class="desc" v-html="renderMarkdown(b.description)"></div>
<p class="action-links">
- <a v-if="b.has_archive" :href="`/bookmarks/${b.id}/archive`" target="_blank">Archive</a>
+ <span class="meta">{{ formatDate(b.updated_at) }}</span>
+ <template v-if="b.has_archive">
+ <span class="sep">|</span>
+ <a :href="`/bookmarks/${b.id}/archive`" target="_blank">Archive</a>
+ </template>
</p>
- <div v-if="b.description" class="desc" v-html="renderMarkdown(b.description)"></div>
</article>
</div>
</section>