commit 0df5a795894a1355d2ec4d026c07a952b01d33c9
parent 493de0c9cefe36622516d5f01d0fd758ae6d4561
Author: David Eisinger <[email protected]>
Date: Mon, 13 Oct 2025 00:05:37 -0400
Fix search pagination
Diffstat:
6 files changed, 230 insertions(+), 87 deletions(-)
diff --git a/internal/api.go b/internal/api.go
@@ -414,57 +414,139 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) {
html = nb
}
}
- // Pagination params
- q := r.URL.Query()
+ // Pagination + search params
+ uq := r.URL.Query()
page := 1
- if pstr := strings.TrimSpace(q.Get("page")); pstr != "" {
- if pv, e := strconv.Atoi(pstr); e == nil && pv > 0 { page = pv }
+ if pstr := strings.TrimSpace(uq.Get("page")); pstr != "" {
+ if pv, e := strconv.Atoi(pstr); e == nil && pv > 0 {
+ page = pv
+ }
}
size := 20
- if sstr := strings.TrimSpace(q.Get("size")); sstr != "" {
- if sv, e := strconv.Atoi(sstr); e == nil && (sv == 20 || sv == 50 || sv == 100) { size = sv }
+ if sstr := strings.TrimSpace(uq.Get("size")); sstr != "" {
+ if sv, e := strconv.Atoi(sstr); e == nil && (sv == 20 || sv == 50 || sv == 100) {
+ size = sv
+ }
}
- offset := (page-1) * size
+ offset := (page - 1) * size
+ query := strings.TrimSpace(uq.Get("q"))
- // Embed initial results for the current page
- var items []Bookmark
- if isOwnerAuthorized(r, u, db) {
- items, err = db.ListBookmarks(r.Context(), u.ID, size, offset)
+ // Embed initial results for the current page (search-aware)
+ if query != "" {
+ var resp []bookmarkResponse
+ var listErr error
+ if isOwnerAuthorized(r, u, db) {
+ if hits, e := db.SearchBookmarksWithExcerpt(r.Context(), u.ID, query, size, offset); e == nil {
+ resp = make([]bookmarkResponse, 0, len(hits))
+ for i := range hits {
+ h := hits[i]
+ br := toRespWithArchive(r.Context(), db, &h.Bookmark)
+ if h.Excerpt.Valid {
+ ex := h.Excerpt.String
+ br.Excerpt = &ex
+ }
+ resp = append(resp, br)
+ }
+ } else {
+ listErr = e
+ }
+ } else {
+ if hits, e := db.SearchPublicBookmarksWithExcerpt(r.Context(), u.ID, query, size, offset); e == nil {
+ resp = make([]bookmarkResponse, 0, len(hits))
+ for i := range hits {
+ h := hits[i]
+ br := toRespWithArchive(r.Context(), db, &h.Bookmark)
+ if h.Excerpt.Valid {
+ ex := h.Excerpt.String
+ br.Excerpt = &ex
+ }
+ resp = append(resp, br)
+ }
+ } else {
+ listErr = e
+ }
+ }
+ if listErr == nil {
+ if b, err := json.Marshal(resp); err == nil {
+ view := "public"
+ if isOwnerAuthorized(r, u, db) {
+ view = "self"
+ }
+ inject := []byte("<script>window.MARKY_INITIAL={view:'" + view + "',user:" + strconv.Quote(u.Username) + ",results:" + string(b) + "}</script>\n")
+ marker := []byte("<script src=\"/app.js\"></script>")
+ if i := bytes.Index(html, marker); i >= 0 {
+ out := make([]byte, 0, len(html)+len(inject))
+ out = append(out, html[:i]...)
+ out = append(out, inject...)
+ out = append(out, html[i:]...)
+ html = out
+ }
+ }
+ }
} else {
- items, err = db.ListPublicBookmarks(r.Context(), u.ID, size, offset)
- }
- if err == nil {
- resp := make([]bookmarkResponse, 0, len(items))
- for i := range items {
- resp = append(resp, toRespWithArchive(r.Context(), db, &items[i]))
+ var items []Bookmark
+ if isOwnerAuthorized(r, u, db) {
+ items, err = db.ListBookmarks(r.Context(), u.ID, size, offset)
+ } else {
+ items, err = db.ListPublicBookmarks(r.Context(), u.ID, size, offset)
}
- if b, err := json.Marshal(resp); err == nil {
- view := "public"
- if isOwnerAuthorized(r, u, db) {
- view = "self"
+ if err == nil {
+ resp := make([]bookmarkResponse, 0, len(items))
+ for i := range items {
+ resp = append(resp, toRespWithArchive(r.Context(), db, &items[i]))
}
- inject := []byte("<script>window.MARKY_INITIAL={view:'" + view + "',user:" + strconv.Quote(u.Username) + ",results:" + string(b) + "}</script>\n")
- marker := []byte("<script src=\"/app.js\"></script>")
- if i := bytes.Index(html, marker); i >= 0 {
- out := make([]byte, 0, len(html)+len(inject))
- out = append(out, html[:i]...)
- out = append(out, inject...)
- out = append(out, html[i:]...)
- html = out
+ if b, err := json.Marshal(resp); err == nil {
+ view := "public"
+ if isOwnerAuthorized(r, u, db) {
+ view = "self"
+ }
+ inject := []byte("<script>window.MARKY_INITIAL={view:'" + view + "',user:" + strconv.Quote(u.Username) + ",results:" + string(b) + "}</script>\n")
+ marker := []byte("<script src=\"/app.js\"></script>")
+ if i := bytes.Index(html, marker); i >= 0 {
+ out := make([]byte, 0, len(html)+len(inject))
+ out = append(out, html[:i]...)
+ out = append(out, inject...)
+ out = append(out, html[i:]...)
+ html = out
+ }
}
}
}
- // Embed pagination meta
+ // Embed pagination meta (search-aware)
total := 0
- if isOwnerAuthorized(r, u, db) {
- if n, e := db.CountBookmarks(r.Context(), u.ID); e == nil { total = n }
+ if query != "" {
+ if isOwnerAuthorized(r, u, db) {
+ if n, e := db.CountSearchBookmarks(r.Context(), u.ID, query); e == nil {
+ total = n
+ }
+ } else {
+ if n, e := db.CountSearchPublicBookmarks(r.Context(), u.ID, query); e == nil {
+ total = n
+ }
+ }
} else {
- if n, e := db.CountPublicBookmarks(r.Context(), u.ID); e == nil { total = n }
+ if isOwnerAuthorized(r, u, db) {
+ if n, e := db.CountBookmarks(r.Context(), u.ID); e == nil {
+ total = n
+ }
+ } else {
+ if n, e := db.CountPublicBookmarks(r.Context(), u.ID); e == nil {
+ total = n
+ }
+ }
}
totalPages := 1
- if size > 0 { totalPages = (total + size - 1) / size }
- if totalPages < 1 { totalPages = 1 }
+ if size > 0 {
+ totalPages = (total + size - 1) / size
+ }
+ if totalPages < 1 {
+ totalPages = 1
+ }
pageObj := map[string]any{"page": page, "size": size, "total": totalPages, "username": u.Username, "owner": isOwnerAuthorized(r, u, db)}
+ if query != "" {
+ pageObj["query"] = query
+ pageObj["queryEncoded"] = url.QueryEscape(query)
+ }
if pb, e := json.Marshal(pageObj); e == nil {
pinject := []byte("<script>window.MARKY_PAGINATION=" + string(pb) + "</script>\n")
marker := []byte("<script src=\"/app.js\"></script>")
diff --git a/internal/database.go b/internal/database.go
@@ -333,11 +333,11 @@ func (db *DB) ListBookmarks(ctx context.Context, userID int64, limit, offset int
// CountBookmarks returns the total number of bookmarks for a user.
func (db *DB) CountBookmarks(ctx context.Context, userID int64) (int, error) {
- var n int
- if err := db.sql.QueryRowContext(ctx, `SELECT COUNT(1) FROM bookmarks WHERE user_id = ?`, userID).Scan(&n); err != nil {
- return 0, fmt.Errorf("count bookmarks: %w", err)
- }
- return n, nil
+ var n int
+ if err := db.sql.QueryRowContext(ctx, `SELECT COUNT(1) FROM bookmarks WHERE user_id = ?`, userID).Scan(&n); err != nil {
+ return 0, fmt.Errorf("count bookmarks: %w", err)
+ }
+ return n, nil
}
// UpdateBookmark updates fields for a given bookmark.
@@ -673,6 +673,81 @@ func (db *DB) SearchBookmarksWithExcerpt(ctx context.Context, userID int64, quer
return out, nil
}
+// CountSearchBookmarks returns the total number of FTS matches for the user.
+func (db *DB) CountSearchBookmarks(ctx context.Context, userID int64, query string) (int, error) {
+ var n int
+ err := db.sql.QueryRowContext(ctx, `
+ SELECT COUNT(1)
+ FROM bookmarks_fts AS f
+ JOIN bookmarks AS b ON b.id = f.rowid
+ WHERE bookmarks_fts MATCH ? AND b.user_id = ?
+ `, query, userID).Scan(&n)
+ if err != nil {
+ return 0, fmt.Errorf("count fts matches: %w", err)
+ }
+ return n, nil
+}
+
+// SearchPublicBookmarksWithExcerpt performs an FTS5 search limited to public bookmarks
+// and also returns a snippet excerpt from the archived content column highlighting matches.
+func (db *DB) SearchPublicBookmarksWithExcerpt(ctx context.Context, userID int64, 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.referrer, b.public, b.user_id, b.created_at, b.updated_at
+ FROM bookmarks_fts AS f
+ JOIN bookmarks AS b ON b.id = f.rowid
+ WHERE bookmarks_fts MATCH ? AND b.user_id = ? AND b.public = 1
+ ORDER BY bm25(bookmarks_fts)
+ LIMIT ? OFFSET ?
+ `, query, userID, limit, offset)
+ if err != nil {
+ return nil, fmt.Errorf("fts public 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, &b.Referrer, &b.Public, &b.UserID, &created, &updated); err != nil {
+ return nil, fmt.Errorf("scan fts public 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 public: %w", err)
+ }
+ return out, nil
+}
+
+// CountSearchPublicBookmarks returns the total number of FTS matches for public bookmarks only.
+func (db *DB) CountSearchPublicBookmarks(ctx context.Context, userID int64, query string) (int, error) {
+ var n int
+ err := db.sql.QueryRowContext(ctx, `
+ SELECT COUNT(1)
+ FROM bookmarks_fts AS f
+ JOIN bookmarks AS b ON b.id = f.rowid
+ WHERE bookmarks_fts MATCH ? AND b.user_id = ? AND b.public = 1
+ `, query, userID).Scan(&n)
+ if err != nil {
+ return 0, fmt.Errorf("count fts public matches: %w", err)
+ }
+ return n, 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.
diff --git a/internal/user_db.go b/internal/user_db.go
@@ -137,11 +137,11 @@ func (db *DB) ListPublicBookmarks(ctx context.Context, userID int64, limit, offs
// CountPublicBookmarks returns the total number of public bookmarks for a user.
func (db *DB) CountPublicBookmarks(ctx context.Context, userID int64) (int, error) {
- var n int
- if err := db.sql.QueryRowContext(ctx, `SELECT COUNT(1) FROM bookmarks WHERE user_id = ? AND public = 1`, userID).Scan(&n); err != nil {
- return 0, fmt.Errorf("count public bookmarks: %w", err)
- }
- return n, nil
+ var n int
+ if err := db.sql.QueryRowContext(ctx, `SELECT COUNT(1) FROM bookmarks WHERE user_id = ? AND public = 1`, userID).Scan(&n); err != nil {
+ return 0, fmt.Errorf("count public bookmarks: %w", err)
+ }
+ return n, nil
}
// GetAPIToken returns the user's API token if set.
diff --git a/internal/web/app.js b/internal/web/app.js
@@ -7,8 +7,7 @@ createApp({
busy: false,
error: "",
message: "",
- q: "",
- busySearch: false,
+
results: [],
view: 'login', // 'login' | 'signup' | 'self' | 'public'
disableSignup: !!(window.MARKY_CFG && window.MARKY_CFG.disableSignup),
@@ -210,28 +209,15 @@ createApp({
this.form.description = "";
this.form.referrer = "";
this.form.public = false;
- // refresh results (use current query)
- this.search();
+ // refresh the page (preserves any current query params)
+ location.reload();
} catch (e) {
this.error = e.message || String(e);
} finally {
this.busy = false;
}
},
- async search() {
- this.busySearch = true;
- try {
- const url = this.q ? `/bookmarks?q=${encodeURIComponent(this.q)}` : "/bookmarks";
- const res = await fetch(url);
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- const arr = await res.json();
- this.results = this.normalizeResults(arr);
- } catch (e) {
- console.error(e);
- } finally {
- this.busySearch = false;
- }
- },
+
// loadPublic removed; server embeds data for public view
bookmarkletHref() {
// Build the bookmarklet installer link dynamically
diff --git a/internal/web/index.html b/internal/web/index.html
@@ -80,9 +80,9 @@
<a :href="`/u:${me.username}.rss`" title="Your RSS feed (includes private if logged in)">RSS</a>
</span>
</h2>
- <form @submit.prevent="search">
- <input v-model.trim="q" type="search" placeholder="Search…" />
- <button :disabled="busySearch">{{ busySearch ? 'Searching…' : 'Search' }}</button>
+ <form method="GET" :action="me && me.username ? `/u:${me.username}` : `/`">
+ <input name="q" type="search" placeholder="Search…" :value="pager && pager.query ? pager.query : ''" />
+ <button>Search</button>
</form>
<div class="results">
<article v-for="b in results" :key="b.id" :class="['card', { private: !b.public }]">
diff --git a/internal/web/user.html b/internal/web/user.html
@@ -42,9 +42,9 @@
<a :href="`/u:${me.username}.rss`" title="Your RSS feed (includes private if logged in)">RSS</a>
</span>
</h2>
- <form @submit.prevent="search">
- <input v-model.trim="q" type="search" placeholder="Search…" />
- <button :disabled="busySearch">{{ busySearch ? 'Searching…' : 'Search' }}</button>
+ <form method="GET" :action="`/u:${me.username}`">
+ <input name="q" type="search" placeholder="Search…" :value="pager && pager.query ? pager.query : ''" />
+ <button>Search</button>
</form>
<div class="results">
<article v-for="b in results" :key="b.id" :class="['card', { private: !b.public }]">
@@ -80,35 +80,35 @@
<div class="pager">
<div v-if="pager && pager.total > 1" class="pages">
<template v-if="pager.page > 1">
- <a :href="`/u:${me.username}?page=${pager.page-1}&size=${pager.size}`">Prev</a>
+ <a :href="pager && pager.query ? `/u:${me.username}?page=${pager.page-1}&size=${pager.size}&q=${pager.queryEncoded}` : `/u:${me.username}?page=${pager.page-1}&size=${pager.size}`">Prev</a>
</template>
<template v-else>
- <a class="muted" :href="`/u:${me.username}?page=${pager.page}&size=${pager.size}`">Prev</a>
+ <a class="muted" :href="pager && pager.query ? `/u:${me.username}?page=${pager.page}&size=${pager.size}&q=${pager.queryEncoded}` : `/u:${me.username}?page=${pager.page}&size=${pager.size}`">Prev</a>
</template>
<span class="sep">|</span>
<template v-for="n in pager.total" :key="n">
<template v-if="n === pager.page">
- <a class="muted" :href="`/u:${me.username}?page=${n}&size=${pager.size}`">{{ n }}</a>
+ <a class="muted" :href="pager && pager.query ? `/u:${me.username}?page=${n}&size=${pager.size}&q=${pager.queryEncoded}` : `/u:${me.username}?page=${n}&size=${pager.size}`">{{ n }}</a>
</template>
<template v-else>
- <a :href="`/u:${me.username}?page=${n}&size=${pager.size}`">{{ n }}</a>
+ <a :href="pager && pager.query ? `/u:${me.username}?page=${n}&size=${pager.size}&q=${pager.queryEncoded}` : `/u:${me.username}?page=${n}&size=${pager.size}`">{{ n }}</a>
</template>
</template>
<span class="sep">|</span>
<template v-if="pager.page < pager.total">
- <a :href="`/u:${me.username}?page=${pager.page+1}&size=${pager.size}`">Next</a>
+ <a :href="pager && pager.query ? `/u:${me.username}?page=${pager.page+1}&size=${pager.size}&q=${pager.queryEncoded}` : `/u:${me.username}?page=${pager.page+1}&size=${pager.size}`">Next</a>
</template>
<template v-else>
- <a class="muted" :href="`/u:${me.username}?page=${pager.page}&size=${pager.size}`">Next</a>
+ <a class="muted" :href="pager && pager.query ? `/u:${me.username}?page=${pager.page}&size=${pager.size}&q=${pager.queryEncoded}` : `/u:${me.username}?page=${pager.page}&size=${pager.size}`">Next</a>
</template>
</div>
<div class="sizes">
<span>Page size:</span>
- <a :href="`/u:${me.username}?page=1&size=20`" :class="{ muted: pager && pager.size===20 }">20</a>
+ <a :href="pager && pager.query ? `/u:${me.username}?page=1&size=20&q=${pager.queryEncoded}` : `/u:${me.username}?page=1&size=20`" :class="{ muted: pager && pager.size===20 }">20</a>
<span class="sep">|</span>
- <a :href="`/u:${me.username}?page=1&size=50`" :class="{ muted: pager && pager.size===50 }">50</a>
+ <a :href="pager && pager.query ? `/u:${me.username}?page=1&size=50&q=${pager.queryEncoded}` : `/u:${me.username}?page=1&size=50`" :class="{ muted: pager && pager.size===50 }">50</a>
<span class="sep">|</span>
- <a :href="`/u:${me.username}?page=1&size=100`" :class="{ muted: pager && pager.size===100 }">100</a>
+ <a :href="pager && pager.query ? `/u:${me.username}?page=1&size=100&q=${pager.queryEncoded}` : `/u:${me.username}?page=1&size=100`" :class="{ muted: pager && pager.size===100 }">100</a>
</div>
</div>
</section>
@@ -140,35 +140,35 @@
<div class="pager">
<div v-if="pager && pager.total > 1" class="pages">
<template v-if="pager.page > 1">
- <a :href="`/u:${viewUser}?page=${pager.page-1}&size=${pager.size}`">Prev</a>
+ <a :href="pager && pager.query ? `/u:${viewUser}?page=${pager.page-1}&size=${pager.size}&q=${pager.queryEncoded}` : `/u:${viewUser}?page=${pager.page-1}&size=${pager.size}`">Prev</a>
</template>
<template v-else>
- <a class="muted" :href="`/u:${viewUser}?page=${pager.page}&size=${pager.size}`">Prev</a>
+ <a class="muted" :href="pager && pager.query ? `/u:${viewUser}?page=${pager.page}&size=${pager.size}&q=${pager.queryEncoded}` : `/u:${viewUser}?page=${pager.page}&size=${pager.size}`">Prev</a>
</template>
<span class="sep">|</span>
<template v-for="n in pager.total" :key="n">
<template v-if="n === pager.page">
- <a class="muted" :href="`/u:${viewUser}?page=${n}&size=${pager.size}`">{{ n }}</a>
+ <a class="muted" :href="pager && pager.query ? `/u:${viewUser}?page=${n}&size=${pager.size}&q=${pager.queryEncoded}` : `/u:${viewUser}?page=${n}&size=${pager.size}`">{{ n }}</a>
</template>
<template v-else>
- <a :href="`/u:${viewUser}?page=${n}&size=${pager.size}`">{{ n }}</a>
+ <a :href="pager && pager.query ? `/u:${viewUser}?page=${n}&size=${pager.size}&q=${pager.queryEncoded}` : `/u:${viewUser}?page=${n}&size=${pager.size}`">{{ n }}</a>
</template>
</template>
<span class="sep">|</span>
<template v-if="pager.page < pager.total">
- <a :href="`/u:${viewUser}?page=${pager.page+1}&size=${pager.size}`">Next</a>
+ <a :href="pager && pager.query ? `/u:${viewUser}?page=${pager.page+1}&size=${pager.size}&q=${pager.queryEncoded}` : `/u:${viewUser}?page=${pager.page+1}&size=${pager.size}`">Next</a>
</template>
<template v-else>
- <a class="muted" :href="`/u:${viewUser}?page=${pager.page}&size=${pager.size}`">Next</a>
+ <a class="muted" :href="pager && pager.query ? `/u:${viewUser}?page=${pager.page}&size=${pager.size}&q=${pager.queryEncoded}` : `/u:${viewUser}?page=${pager.page}&size=${pager.size}`">Next</a>
</template>
</div>
<div class="sizes">
<span>Page size:</span>
- <a :href="`/u:${viewUser}?page=1&size=20`" :class="{ muted: pager && pager.size===20 }">20</a>
+ <a :href="pager && pager.query ? `/u:${viewUser}?page=1&size=20&q=${pager.queryEncoded}` : `/u:${viewUser}?page=1&size=20`" :class="{ muted: pager && pager.size===20 }">20</a>
<span class="sep">|</span>
- <a :href="`/u:${viewUser}?page=1&size=50`" :class="{ muted: pager && pager.size===50 }">50</a>
+ <a :href="pager && pager.query ? `/u:${viewUser}?page=1&size=50&q=${pager.queryEncoded}` : `/u:${viewUser}?page=1&size=50`" :class="{ muted: pager && pager.size===50 }">50</a>
<span class="sep">|</span>
- <a :href="`/u:${viewUser}?page=1&size=100`" :class="{ muted: pager && pager.size===100 }">100</a>
+ <a :href="pager && pager.query ? `/u:${viewUser}?page=1&size=100&q=${pager.queryEncoded}` : `/u:${viewUser}?page=1&size=100`" :class="{ muted: pager && pager.size===100 }">100</a>
</div>
</div>
</section>