commit fa5b5778cdf4ebe4316f8d283d664837eb1cf706
parent b24e0d7c6917db936ff124ff9f137e6f00613edc
Author: David Eisinger <[email protected]>
Date: Mon, 13 Oct 2025 15:27:39 -0400
Public/private filters
Diffstat:
7 files changed, 216 insertions(+), 18 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -2,3 +2,4 @@ bin
coverage.out
dist
scripts
+.gocache
diff --git a/internal/api.go b/internal/api.go
@@ -430,25 +430,61 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) {
}
offset := (page - 1) * size
query := strings.TrimSpace(uq.Get("q"))
+ vis := strings.TrimSpace(uq.Get("vis"))
+ if vis == "" {
+ vis = "all"
+ }
// 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
+ if strings.ToLower(vis) == "public" {
+ 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)
}
- resp = append(resp, br)
+ } else {
+ listErr = e
+ }
+ } else if strings.ToLower(vis) == "private" {
+ if hits, e := db.SearchPrivateBookmarksWithExcerpt(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 {
- listErr = e
+ 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 {
@@ -486,7 +522,14 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) {
} else {
var items []Bookmark
if isOwnerAuthorized(r, u, db) {
- items, err = db.ListBookmarks(r.Context(), u.ID, size, offset)
+ switch strings.ToLower(vis) {
+ case "public":
+ items, err = db.ListPublicBookmarks(r.Context(), u.ID, size, offset)
+ case "private":
+ items, err = db.ListPrivateBookmarks(r.Context(), u.ID, size, offset)
+ default:
+ items, err = db.ListBookmarks(r.Context(), u.ID, size, offset)
+ }
} else {
items, err = db.ListPublicBookmarks(r.Context(), u.ID, size, offset)
}
@@ -516,8 +559,19 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) {
total := 0
if query != "" {
if isOwnerAuthorized(r, u, db) {
- if n, e := db.CountSearchBookmarks(r.Context(), u.ID, query); e == nil {
- total = n
+ switch strings.ToLower(vis) {
+ case "public":
+ if n, e := db.CountSearchPublicBookmarks(r.Context(), u.ID, query); e == nil {
+ total = n
+ }
+ case "private":
+ if n, e := db.CountSearchPrivateBookmarks(r.Context(), u.ID, query); e == nil {
+ total = n
+ }
+ default:
+ 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 {
@@ -526,8 +580,19 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) {
}
} else {
if isOwnerAuthorized(r, u, db) {
- if n, e := db.CountBookmarks(r.Context(), u.ID); e == nil {
- total = n
+ switch strings.ToLower(vis) {
+ case "public":
+ if n, e := db.CountPublicBookmarks(r.Context(), u.ID); e == nil {
+ total = n
+ }
+ case "private":
+ if n, e := db.CountPrivateBookmarks(r.Context(), u.ID); e == nil {
+ total = n
+ }
+ default:
+ 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 {
@@ -547,6 +612,9 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) {
pageObj["query"] = query
pageObj["queryEncoded"] = url.QueryEscape(query)
}
+ if isOwnerAuthorized(r, u, db) {
+ pageObj["vis"] = vis
+ }
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
@@ -748,6 +748,65 @@ func (db *DB) CountSearchPublicBookmarks(ctx context.Context, userID int64, quer
return n, nil
}
+// SearchPrivateBookmarksWithExcerpt performs an FTS5 search limited to private bookmarks
+// and also returns a snippet excerpt from the archived content column highlighting matches.
+func (db *DB) SearchPrivateBookmarksWithExcerpt(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 = 0
+ ORDER BY bm25(bookmarks_fts)
+ LIMIT ? OFFSET ?
+ `, query, userID, limit, offset)
+ if err != nil {
+ return nil, fmt.Errorf("fts private 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 private 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)
+ }
+ 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 private: %w", err)
+ }
+ return out, nil
+}
+
+// CountSearchPrivateBookmarks returns the total number of FTS matches for private bookmarks only.
+func (db *DB) CountSearchPrivateBookmarks(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 = 0
+ `, query, userID).Scan(&n)
+ if err != nil {
+ return 0, fmt.Errorf("count fts private 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
@@ -144,6 +144,52 @@ func (db *DB) CountPublicBookmarks(ctx context.Context, userID int64) (int, erro
return n, nil
}
+// ListPrivateBookmarks lists only private bookmarks (public = 0) for a given user.
+func (db *DB) ListPrivateBookmarks(ctx context.Context, userID int64, limit, offset int) ([]Bookmark, error) {
+ if limit <= 0 {
+ limit = 50
+ }
+ rows, err := db.sql.QueryContext(ctx, `
+ SELECT id, url, title, description, referrer, public, user_id, created_at, updated_at
+ FROM bookmarks WHERE user_id = ? AND public = 0
+ ORDER BY updated_at DESC
+ LIMIT ? OFFSET ?
+ `, userID, limit, offset)
+ if err != nil {
+ return nil, fmt.Errorf("query private bookmarks: %w", err)
+ }
+ defer rows.Close()
+ out := make([]Bookmark, 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: %w", err)
+ }
+ var e error
+ if b.CreatedAt, e = time.Parse(time.RFC3339Nano, created); e != nil {
+ return nil, e
+ }
+ if b.UpdatedAt, e = time.Parse(time.RFC3339Nano, updated); e != nil {
+ return nil, e
+ }
+ out = append(out, b)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+// CountPrivateBookmarks returns the total number of private bookmarks for a user.
+func (db *DB) CountPrivateBookmarks(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 = 0`, userID).Scan(&n); err != nil {
+ return 0, fmt.Errorf("count private bookmarks: %w", err)
+ }
+ return n, nil
+}
+
// GetAPIToken returns the user's API token if set.
func (db *DB) GetAPIToken(ctx context.Context, userID int64) (string, bool, error) {
var tok sql.NullString
diff --git a/internal/web/index.html b/internal/web/index.html
@@ -82,10 +82,20 @@
</h2>
<form method="GET" :action="me && me.username ? `/u:${me.username}` : `/`">
<input name="q" type="search" placeholder="Search…" :value="pager && pager.query ? pager.query : ''" />
+ <input type="hidden" name="vis" :value="pager && pager.vis ? pager.vis : 'all'" />
<button>Search</button>
</form>
<p v-if="pager" class="muted results-summary">
- Displaying {{ (pager.count||0) === 0 ? 0 : ((pager.page-1)*pager.size + 1) }}-{{ (pager.count||0) === 0 ? 0 : ((pager.page-1)*pager.size + results.length) }} of {{ pager.count || 0 }} bookmarks
+ <span>
+ Displaying {{ (pager.count||0) === 0 ? 0 : ((pager.page-1)*pager.size + 1) }}-{{ (pager.count||0) === 0 ? 0 : ((pager.page-1)*pager.size + results.length) }} of {{ pager.count || 0 }} bookmarks
+ </span>
+ <span class="results-filter" v-if="pager && pager.owner">
+ <a :class="{ selected: (pager.vis||'all')==='all' }" :href="pager && pager.query ? `/u:${me.username}?page=1&size=${pager.size}&q=${pager.queryEncoded}&vis=all` : `/u:${me.username}?page=1&size=${pager.size}&vis=all`">All</a>
+ <span class="sep">|</span>
+ <a :class="{ selected: (pager.vis||'all')==='public' }" :href="pager && pager.query ? `/u:${me.username}?page=1&size=${pager.size}&q=${pager.queryEncoded}&vis=public` : `/u:${me.username}?page=1&size=${pager.size}&vis=public`">Public</a>
+ <span class="sep">|</span>
+ <a :class="{ selected: (pager.vis||'all')==='private' }" :href="pager && pager.query ? `/u:${me.username}?page=1&size=${pager.size}&q=${pager.queryEncoded}&vis=private` : `/u:${me.username}?page=1&size=${pager.size}&vis=private`">Private</a>
+ </span>
</p>
<div class="results">
<article v-for="b in results" :key="b.id" :class="['card', { private: !b.public }]">
diff --git a/internal/web/styles.css b/internal/web/styles.css
@@ -14,7 +14,11 @@ header { border-bottom: 1px solid #ddd3; }
.container h1 a:hover { text-decoration: underline; }
.muted { color: #666; margin: 0 0 8px 0; font-size: 13px; }
/* Ensure the results summary has balanced vertical spacing */
-.results-summary { margin-top: 12px; }
+.results-summary { margin-top: 12px; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
+.results-filter { font-size: 13px; color: #555; white-space: nowrap; }
+.results-filter a { color: inherit; }
+.results-filter a.selected { font-weight: 600; text-decoration: none; }
+.results-filter .sep { margin: 0 6px; opacity: 0.6; }
.top-links { font-size: 13px; color: #555; display: flex; flex-direction: column; align-items: flex-end; text-align: right; gap: 10px; }
.top-links .user-line { white-space: nowrap; }
.top-links a { color: inherit; }
diff --git a/internal/web/user.html b/internal/web/user.html
@@ -44,10 +44,20 @@
</h2>
<form method="GET" :action="`/u:${me.username}`">
<input name="q" type="search" placeholder="Search…" :value="pager && pager.query ? pager.query : ''" />
+ <input type="hidden" name="vis" :value="pager && pager.vis ? pager.vis : 'all'" />
<button>Search</button>
</form>
<p v-if="pager" class="muted results-summary">
- Displaying {{ (pager.count||0) === 0 ? 0 : ((pager.page-1)*pager.size + 1) }}-{{ (pager.count||0) === 0 ? 0 : ((pager.page-1)*pager.size + results.length) }} of {{ pager.count || 0 }} bookmarks
+ <span>
+ Displaying {{ (pager.count||0) === 0 ? 0 : ((pager.page-1)*pager.size + 1) }}-{{ (pager.count||0) === 0 ? 0 : ((pager.page-1)*pager.size + results.length) }} of {{ pager.count || 0 }} bookmarks
+ </span>
+ <span class="results-filter" v-if="pager && pager.owner">
+ <a :class="{ selected: (pager.vis||'all')==='all' }" :href="pager && pager.query ? `/u:${me.username}?page=1&size=${pager.size}&q=${pager.queryEncoded}&vis=all` : `/u:${me.username}?page=1&size=${pager.size}&vis=all`">All</a>
+ <span class="sep">|</span>
+ <a :class="{ selected: (pager.vis||'all')==='public' }" :href="pager && pager.query ? `/u:${me.username}?page=1&size=${pager.size}&q=${pager.queryEncoded}&vis=public` : `/u:${me.username}?page=1&size=${pager.size}&vis=public`">Public</a>
+ <span class="sep">|</span>
+ <a :class="{ selected: (pager.vis||'all')==='private' }" :href="pager && pager.query ? `/u:${me.username}?page=1&size=${pager.size}&q=${pager.queryEncoded}&vis=private` : `/u:${me.username}?page=1&size=${pager.size}&vis=private`">Private</a>
+ </span>
</p>
<div class="results">
<article v-for="b in results" :key="b.id" :class="['card', { private: !b.public }]">