commit 17fea11d6978da13b2acdb4d5adba8f644875bf3
parent 97c0eb383058b979acbb5dcf7a17340fded79297
Author: David Eisinger <[email protected]>
Date: Mon, 6 Oct 2025 14:01:33 -0400
Search excerpt
Diffstat:
3 files changed, 163 insertions(+), 15 deletions(-)
diff --git a/README.md b/README.md
@@ -64,7 +64,7 @@ curl -i -X POST http://localhost:8080/bookmarks \
### GET /bookmarks
-List bookmarks in reverse chronological order, or search with full-text.
+List bookmarks in reverse chronological order, or search with full-text. When `q` is provided, results include an `excerpt` with highlighted matches.
- Query params:
- `limit` (optional, default 50, max 200)
@@ -81,6 +81,10 @@ curl -s 'http://localhost:8080/bookmarks?limit=20&offset=20' | jq
curl -s 'http://localhost:8080/bookmarks?q=example' | jq
```
+Notes on search excerpts:
+- `excerpt` highlights matches in square brackets, e.g., `... some [example] text ...`.
+- Ellipses `…` indicate omitted surrounding text, and snippets focus on the archived page content.
+
## Data Model
Table: `bookmarks`
diff --git a/cmd/marky.go b/cmd/marky.go
@@ -29,6 +29,7 @@ type bookmarkResponse struct {
Description *string `json:"description,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
+ Excerpt *string `json:"excerpt,omitempty"`
}
func toResp(b *dbpkg.Bookmark) bookmarkResponse {
@@ -171,22 +172,36 @@ func main() {
}
}
- var items []dbpkg.Bookmark
- var err error
+ var resp []bookmarkResponse
if query != "" {
- items, err = db.SearchBookmarks(r.Context(), query, limit, offset)
+ hits, err := db.SearchBookmarksWithExcerpt(r.Context(), query, limit, offset)
+ if err != nil {
+ log.Printf("list bookmarks error: %v", err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
+ resp = make([]bookmarkResponse, 0, len(hits))
+ for i := range hits {
+ h := hits[i]
+ br := toResp(&h.Bookmark)
+ if h.Excerpt.Valid {
+ ex := h.Excerpt.String
+ br.Excerpt = &ex
+ }
+ resp = append(resp, br)
+ }
} else {
- items, err = db.ListBookmarks(r.Context(), limit, offset)
- }
- if err != nil {
- log.Printf("list bookmarks error: %v", err)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- resp := make([]bookmarkResponse, 0, len(items))
- for i := range items {
- b := items[i]
- resp = append(resp, toResp(&b))
+ items, err := db.ListBookmarks(r.Context(), limit, offset)
+ if err != nil {
+ log.Printf("list bookmarks error: %v", err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
+ resp = make([]bookmarkResponse, 0, len(items))
+ for i := range items {
+ b := items[i]
+ resp = append(resp, toResp(&b))
+ }
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
diff --git a/internal/database.go b/internal/database.go
@@ -29,6 +29,12 @@ type Bookmark struct {
UpdatedAt time.Time
}
+// SearchResult is a bookmark match with an optional excerpt snippet.
+type SearchResult struct {
+ Bookmark Bookmark
+ Excerpt sql.NullString
+}
+
// DB wraps *sql.DB with helpers.
type DB struct {
sql *sql.DB
@@ -493,6 +499,129 @@ func (db *DB) SearchBookmarks(ctx context.Context, query string, limit, offset i
return out, nil
}
+// SearchBookmarksWithExcerpt performs an FTS5 search and also returns a snippet excerpt
+// from the archived content column highlighting matches.
+func (db *DB) SearchBookmarksWithExcerpt(ctx context.Context, query string, limit, offset int) ([]SearchResult, error) {
+ if limit <= 0 {
+ limit = 50
+ }
+ rows, err := db.sql.QueryContext(ctx, `
+ SELECT b.id, b.url, b.title, b.description, b.created_at, b.updated_at
+ FROM bookmarks_fts AS f
+ JOIN bookmarks AS b ON b.id = f.rowid
+ WHERE bookmarks_fts MATCH ?
+ ORDER BY bm25(bookmarks_fts)
+ LIMIT ? OFFSET ?
+ `, query, limit, offset)
+ if err != nil {
+ return nil, fmt.Errorf("fts query: %w", err)
+ }
+ defer rows.Close()
+ out := make([]SearchResult, 0, limit)
+ for rows.Next() {
+ var b Bookmark
+ var created, updated string
+ if err := rows.Scan(&b.ID, &b.URL, &b.Title, &b.Description, &created, &updated); err != nil {
+ return nil, fmt.Errorf("scan fts row: %w", err)
+ }
+ if b.CreatedAt, err = time.Parse(time.RFC3339Nano, created); err != nil {
+ return nil, fmt.Errorf("parse created_at: %w", err)
+ }
+ if b.UpdatedAt, err = time.Parse(time.RFC3339Nano, updated); err != nil {
+ return nil, fmt.Errorf("parse updated_at: %w", err)
+ }
+ // Build excerpt from archives content in Go for portability
+ if content, _, ok, _ := db.GetArchive(ctx, b.ID); ok {
+ ex := sql.NullString{String: makeExcerpt(content, query), Valid: true}
+ out = append(out, SearchResult{Bookmark: b, Excerpt: ex})
+ } else {
+ out = append(out, SearchResult{Bookmark: b})
+ }
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate fts: %w", err)
+ }
+ return out, nil
+}
+
+// makeExcerpt returns a short excerpt from content highlighting the first
+// occurrence of any query token using [brackets]. If no token is found, returns
+// the leading portion of the content.
+func makeExcerpt(content, query string) string {
+ c := content
+ if len(c) == 0 {
+ return ""
+ }
+ lc := strings.ToLower(c)
+ // Split query to tokens by whitespace; ignore empty tokens
+ fields := strings.Fields(strings.ToLower(query))
+ // Find first occurrence index among tokens
+ idx := -1
+ token := ""
+ for _, t := range fields {
+ if t == "" {
+ continue
+ }
+ if i := strings.Index(lc, t); i >= 0 && (idx == -1 || i < idx) {
+ idx = i
+ token = t
+ }
+ }
+ // Window
+ const pre = 80
+ const post = 120
+ if idx == -1 {
+ end := len(c)
+ if end > pre+post {
+ end = pre + post
+ }
+ s := c[:end]
+ if end < len(c) {
+ s += "…"
+ }
+ return s
+ }
+ start := idx - pre
+ if start < 0 {
+ start = 0
+ }
+ end := idx + len(token) + post
+ if end > len(c) {
+ end = len(c)
+ }
+ s := c[start:end]
+ if start > 0 {
+ s = "…" + s
+ }
+ if end < len(c) {
+ s = s + "…"
+ }
+ // Highlight all tokens (basic, case-insensitive)
+ ls := strings.ToLower(s)
+ var b strings.Builder
+ for i := 0; i < len(s); {
+ matched := false
+ for _, t := range fields {
+ if t == "" {
+ continue
+ }
+ if i+len(t) <= len(s) && ls[i:i+len(t)] == t {
+ b.WriteByte('[')
+ b.WriteString(s[i : i+len(t)])
+ b.WriteByte(']')
+ i += len(t)
+ matched = true
+ break
+ }
+ }
+ if !matched {
+ b.WriteByte(s[i])
+ i++
+ }
+ }
+ return b.String()
+}
+
// ftsReplace replaces a row in the contentless FTS5 table using DELETE + INSERT,
// because UPSERT/REPLACE is not implemented for virtual tables.
func ftsReplace(ctx context.Context, ex execer, rowid int64, title, url string, description any, content string) error {