marky

Lightweight bookmarking + plaintext archive
Log | Files | Refs | README | LICENSE

commit 17fea11d6978da13b2acdb4d5adba8f644875bf3
parent 97c0eb383058b979acbb5dcf7a17340fded79297
Author: David Eisinger <[email protected]>
Date:   Mon,  6 Oct 2025 14:01:33 -0400

Search excerpt

Diffstat:
MREADME.md | 6+++++-
Mcmd/marky.go | 43+++++++++++++++++++++++++++++--------------
Minternal/database.go | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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 {