marky

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

commit 0df5a795894a1355d2ec4d026c07a952b01d33c9
parent 493de0c9cefe36622516d5f01d0fd758ae6d4561
Author: David Eisinger <[email protected]>
Date:   Mon, 13 Oct 2025 00:05:37 -0400

Fix search pagination

Diffstat:
Minternal/api.go | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Minternal/database.go | 85++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Minternal/user_db.go | 10+++++-----
Minternal/web/app.js | 22++++------------------
Minternal/web/index.html | 6+++---
Minternal/web/user.html | 42+++++++++++++++++++++---------------------
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>