marky

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

commit fa5b5778cdf4ebe4316f8d283d664837eb1cf706
parent b24e0d7c6917db936ff124ff9f137e6f00613edc
Author: David Eisinger <[email protected]>
Date:   Mon, 13 Oct 2025 15:27:39 -0400

Public/private filters

Diffstat:
M.gitignore | 1+
Minternal/api.go | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Minternal/database.go | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/user_db.go | 46++++++++++++++++++++++++++++++++++++++++++++++
Minternal/web/index.html | 12+++++++++++-
Minternal/web/styles.css | 6+++++-
Minternal/web/user.html | 12+++++++++++-
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 }]">