commit 3ebc107aef28a6f1e402d707647a441d389c263c
parent aa6dfb6591b32c8ed6cf8c437968d0e6d27b051d
Author: David Eisinger <[email protected]>
Date: Mon, 6 Oct 2025 15:50:39 -0400
Public/private
Diffstat:
7 files changed, 175 insertions(+), 135 deletions(-)
diff --git a/internal/api.go b/internal/api.go
@@ -60,14 +60,18 @@ func NewHandler(db *DB) (http.Handler, error) {
http.Error(w, "failed to archive URL", http.StatusBadGateway)
return
}
- title := req.Title
- if strings.TrimSpace(title) == "" {
- if strings.TrimSpace(fetchedTitle) != "" {
- title = fetchedTitle
- } else {
- title = req.URL
- }
- }
+ title := req.Title
+ if strings.TrimSpace(title) == "" {
+ if strings.TrimSpace(fetchedTitle) != "" {
+ title = fetchedTitle
+ } else {
+ title = req.URL
+ }
+ }
+ pub := false
+ if req.Public != nil {
+ pub = *req.Public
+ }
if existing, err := db.GetBookmarkByURL(r.Context(), req.URL); err != nil {
log.Printf("lookup by url error: %v", err)
@@ -95,13 +99,13 @@ func NewHandler(db *DB) (http.Handler, error) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(toResp(updated))
return
- } else {
- created, err := db.CreateBookmarkWithArchive(r.Context(), req.URL, title, req.Description, txt)
- if err != nil {
- log.Printf("create bookmark with archive error: %v", err)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
+ } else {
+ created, err := db.CreateBookmarkWithArchive(r.Context(), req.URL, title, req.Description, pub, txt)
+ if err != nil {
+ log.Printf("create bookmark with archive error: %v", err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(toResp(created))
@@ -260,41 +264,49 @@ func NewHandler(db *DB) (http.Handler, error) {
return
}
switch r.Method {
- case http.MethodPut:
- decoder := json.NewDecoder(r.Body)
- decoder.DisallowUnknownFields()
- var req createBookmarkRequest
- if err := decoder.Decode(&req); err != nil {
- http.Error(w, "invalid JSON payload", http.StatusBadRequest)
- return
- }
- if strings.TrimSpace(req.URL) == "" || strings.TrimSpace(req.Title) == "" {
- http.Error(w, "url and title are required", http.StatusBadRequest)
- return
- }
- desc := req.Description
- if desc == nil {
- existing, err := db.GetBookmark(r.Context(), id64)
- if err != nil {
- log.Printf("get bookmark error: %v", err)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- if existing == nil {
- http.NotFound(w, r)
- return
- }
- if existing.Description.Valid {
- d := existing.Description.String
- desc = &d
- }
- }
- updated, err := db.UpdateBookmark(r.Context(), id64, req.URL, req.Title, desc)
- if err != nil {
- log.Printf("update bookmark error: %v", err)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
+ case http.MethodPut:
+ decoder := json.NewDecoder(r.Body)
+ decoder.DisallowUnknownFields()
+ var req createBookmarkRequest
+ if err := decoder.Decode(&req); err != nil {
+ http.Error(w, "invalid JSON payload", http.StatusBadRequest)
+ return
+ }
+ if strings.TrimSpace(req.URL) == "" || strings.TrimSpace(req.Title) == "" {
+ http.Error(w, "url and title are required", http.StatusBadRequest)
+ return
+ }
+ desc := req.Description
+ var pub bool
+ // Fetch existing if we need to preserve fields
+ if desc == nil || req.Public == nil {
+ existing, err := db.GetBookmark(r.Context(), id64)
+ if err != nil {
+ log.Printf("get bookmark error: %v", err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
+ if existing == nil {
+ http.NotFound(w, r)
+ return
+ }
+ if desc == nil && existing.Description.Valid {
+ d := existing.Description.String
+ desc = &d
+ }
+ if req.Public == nil {
+ pub = existing.Public
+ }
+ }
+ if req.Public != nil {
+ pub = *req.Public
+ }
+ updated, err := db.UpdateBookmark(r.Context(), id64, req.URL, req.Title, desc, pub)
+ if err != nil {
+ log.Printf("update bookmark error: %v", err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
if updated == nil {
http.NotFound(w, r)
return
@@ -323,19 +335,21 @@ func NewHandler(db *DB) (http.Handler, error) {
}
type createBookmarkRequest struct {
- URL string `json:"url"`
- Title string `json:"title"`
- Description *string `json:"description,omitempty"`
+ URL string `json:"url"`
+ Title string `json:"title"`
+ Description *string `json:"description,omitempty"`
+ Public *bool `json:"public,omitempty"`
}
type bookmarkResponse struct {
- ID int64 `json:"id"`
- URL string `json:"url"`
- Title string `json:"title"`
- Description *string `json:"description,omitempty"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
- Excerpt *string `json:"excerpt,omitempty"`
+ ID int64 `json:"id"`
+ URL string `json:"url"`
+ Title string `json:"title"`
+ Description *string `json:"description,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ Excerpt *string `json:"excerpt,omitempty"`
+ Public bool `json:"public"`
}
func toResp(b *Bookmark) bookmarkResponse {
@@ -343,14 +357,15 @@ func toResp(b *Bookmark) bookmarkResponse {
if b.Description.Valid {
desc = &b.Description.String
}
- return bookmarkResponse{
- ID: b.ID,
- URL: b.URL,
- Title: b.Title,
- Description: desc,
- CreatedAt: b.CreatedAt,
- UpdatedAt: b.UpdatedAt,
- }
+ return bookmarkResponse{
+ ID: b.ID,
+ URL: b.URL,
+ Title: b.Title,
+ Description: desc,
+ CreatedAt: b.CreatedAt,
+ UpdatedAt: b.UpdatedAt,
+ Public: b.Public,
+ }
}
// RSS structures and writer
diff --git a/internal/database.go b/internal/database.go
@@ -21,12 +21,13 @@ var migrationsFS embed.FS
// Bookmark represents a single bookmark row.
type Bookmark struct {
- ID int64
- URL string
- Title string
- Description sql.NullString
- CreatedAt time.Time
- UpdatedAt time.Time
+ ID int64
+ URL string
+ Title string
+ Description sql.NullString
+ Public bool
+ CreatedAt time.Time
+ UpdatedAt time.Time
}
// SearchResult is a bookmark match with an optional excerpt snippet.
@@ -175,9 +176,9 @@ func (db *DB) CreateBookmark(ctx context.Context, url, title string, description
if description != nil {
desc = sql.NullString{String: *description, Valid: true}
}
- res, err := db.sql.ExecContext(ctx, `
- INSERT INTO bookmarks(url, title, description, created_at, updated_at)
- VALUES(?, ?, ?, ?, ?)
+ res, err := db.sql.ExecContext(ctx, `
+ INSERT INTO bookmarks(url, title, description, public, created_at, updated_at)
+ VALUES(?, ?, ?, 0, ?, ?)
`, url, title, nullString(desc), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
if err != nil {
return nil, fmt.Errorf("insert bookmark: %w", err)
@@ -194,24 +195,25 @@ func (db *DB) CreateBookmark(ctx context.Context, url, title string, description
if err := ftsReplace(ctx, db.sql, id, title, url, nullString(desc), ftsContent); err != nil {
return nil, fmt.Errorf("fts replace after create: %w", err)
}
- return &Bookmark{
- ID: id,
- URL: url,
- Title: title,
- Description: desc,
- CreatedAt: now,
- UpdatedAt: now,
- }, nil
+ return &Bookmark{
+ ID: id,
+ URL: url,
+ Title: title,
+ Description: desc,
+ Public: false,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }, nil
}
// GetBookmark returns a bookmark by ID.
func (db *DB) GetBookmark(ctx context.Context, id int64) (*Bookmark, error) {
var b Bookmark
var created, updated string
- if err := db.sql.QueryRowContext(ctx, `
- SELECT id, url, title, description, created_at, updated_at
+ if err := db.sql.QueryRowContext(ctx, `
+ SELECT id, url, title, description, public, created_at, updated_at
FROM bookmarks WHERE id = ?
- `, id).Scan(&b.ID, &b.URL, &b.Title, &b.Description, &created, &updated); err != nil {
+ `, id).Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.Public, &created, &updated); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
@@ -231,10 +233,10 @@ func (db *DB) GetBookmark(ctx context.Context, id int64) (*Bookmark, error) {
func (db *DB) GetBookmarkByURL(ctx context.Context, u string) (*Bookmark, error) {
var b Bookmark
var created, updated string
- if err := db.sql.QueryRowContext(ctx, `
- SELECT id, url, title, description, created_at, updated_at
+ if err := db.sql.QueryRowContext(ctx, `
+ SELECT id, url, title, description, public, created_at, updated_at
FROM bookmarks WHERE url = ?
- `, u).Scan(&b.ID, &b.URL, &b.Title, &b.Description, &created, &updated); err != nil {
+ `, u).Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.Public, &created, &updated); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
@@ -255,8 +257,8 @@ func (db *DB) ListBookmarks(ctx context.Context, limit, offset int) ([]Bookmark,
if limit <= 0 {
limit = 50
}
- rows, err := db.sql.QueryContext(ctx, `
- SELECT id, url, title, description, created_at, updated_at
+ rows, err := db.sql.QueryContext(ctx, `
+ SELECT id, url, title, description, public, created_at, updated_at
FROM bookmarks
ORDER BY created_at DESC
LIMIT ? OFFSET ?
@@ -269,9 +271,9 @@ func (db *DB) ListBookmarks(ctx context.Context, limit, offset int) ([]Bookmark,
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 bookmark: %w", err)
- }
+ if err := rows.Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.Public, &created, &updated); err != nil {
+ return nil, fmt.Errorf("scan bookmark: %w", err)
+ }
if b.CreatedAt, err = time.Parse(time.RFC3339Nano, created); err != nil {
return nil, fmt.Errorf("parse created_at: %w", err)
}
@@ -287,7 +289,7 @@ func (db *DB) ListBookmarks(ctx context.Context, limit, offset int) ([]Bookmark,
}
// UpdateBookmark updates fields for a given bookmark.
-func (db *DB) UpdateBookmark(ctx context.Context, id int64, url, title string, description *string) (*Bookmark, error) {
+func (db *DB) UpdateBookmark(ctx context.Context, id int64, url, title string, description *string, public bool) (*Bookmark, error) {
if strings.TrimSpace(url) == "" || strings.TrimSpace(title) == "" {
return nil, fmt.Errorf("url and title are required")
}
@@ -296,11 +298,11 @@ func (db *DB) UpdateBookmark(ctx context.Context, id int64, url, title string, d
if description != nil {
desc = sql.NullString{String: *description, Valid: true}
}
- res, err := db.sql.ExecContext(ctx, `
+ res, err := db.sql.ExecContext(ctx, `
UPDATE bookmarks
- SET url = ?, title = ?, description = ?, updated_at = ?
+ SET url = ?, title = ?, description = ?, public = ?, updated_at = ?
WHERE id = ?
- `, url, title, nullString(desc), now.Format(time.RFC3339Nano), id)
+ `, url, title, nullString(desc), publicToInt(public), now.Format(time.RFC3339Nano), id)
if err != nil {
return nil, fmt.Errorf("update bookmark: %w", err)
}
@@ -312,10 +314,10 @@ func (db *DB) UpdateBookmark(ctx context.Context, id int64, url, title string, d
return nil, nil
}
// Update FTS metadata while preserving content
- var descForFTS sql.NullString
- if description != nil {
- descForFTS = sql.NullString{String: *description, Valid: true}
- }
+ var descForFTS sql.NullString
+ if description != nil {
+ descForFTS = sql.NullString{String: *description, Valid: true}
+ }
ftsContent := ""
if c, _, ok, _ := db.GetArchive(ctx, id); ok {
ftsContent = c
@@ -354,15 +356,22 @@ func (db *DB) DeleteBookmark(ctx context.Context, id int64) (bool, error) {
}
func nullString(ns sql.NullString) any {
- if ns.Valid {
- return ns.String
- }
- return nil
+ if ns.Valid {
+ return ns.String
+ }
+ return nil
+}
+
+func publicToInt(v bool) int {
+ if v {
+ return 1
+ }
+ return 0
}
// CreateBookmarkWithArchive creates a bookmark and saves its plaintext archive
// within a single transaction to ensure consistency.
-func (db *DB) CreateBookmarkWithArchive(ctx context.Context, url, title string, description *string, archiveContent string) (*Bookmark, error) {
+func (db *DB) CreateBookmarkWithArchive(ctx context.Context, url, title string, description *string, public bool, archiveContent string) (*Bookmark, error) {
if strings.TrimSpace(url) == "" || strings.TrimSpace(title) == "" {
return nil, fmt.Errorf("url and title are required")
}
@@ -376,10 +385,10 @@ func (db *DB) CreateBookmarkWithArchive(ctx context.Context, url, title string,
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
- res, err := tx.ExecContext(ctx, `
- INSERT INTO bookmarks(url, title, description, created_at, updated_at)
- VALUES(?, ?, ?, ?, ?)
- `, url, title, nullString(desc), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
+ res, err := tx.ExecContext(ctx, `
+ INSERT INTO bookmarks(url, title, description, public, created_at, updated_at)
+ VALUES(?, ?, ?, ?, ?, ?)
+ `, url, title, nullString(desc), publicToInt(public), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
if err != nil {
_ = tx.Rollback()
return nil, fmt.Errorf("insert bookmark: %w", err)
@@ -404,14 +413,15 @@ func (db *DB) CreateBookmarkWithArchive(ctx context.Context, url, title string,
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
- return &Bookmark{
- ID: id,
- URL: url,
- Title: title,
- Description: desc,
- CreatedAt: now,
- UpdatedAt: now,
- }, nil
+ return &Bookmark{
+ ID: id,
+ URL: url,
+ Title: title,
+ Description: desc,
+ Public: public,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }, nil
}
// SaveArchive upserts the plaintext archive content for a bookmark.
@@ -466,8 +476,8 @@ func (db *DB) SearchBookmarks(ctx context.Context, query string, limit, offset i
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
+ rows, err := db.sql.QueryContext(ctx, `
+ SELECT b.id, b.url, b.title, b.description, b.public, b.created_at, b.updated_at
FROM bookmarks_fts AS f
JOIN bookmarks AS b ON b.id = f.rowid
WHERE bookmarks_fts MATCH ?
@@ -482,9 +492,9 @@ func (db *DB) SearchBookmarks(ctx context.Context, query string, limit, offset i
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 err := rows.Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.Public, &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)
}
@@ -505,8 +515,8 @@ func (db *DB) SearchBookmarksWithExcerpt(ctx context.Context, query string, limi
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
+ rows, err := db.sql.QueryContext(ctx, `
+ SELECT b.id, b.url, b.title, b.description, b.public, b.created_at, b.updated_at
FROM bookmarks_fts AS f
JOIN bookmarks AS b ON b.id = f.rowid
WHERE bookmarks_fts MATCH ?
@@ -521,9 +531,9 @@ func (db *DB) SearchBookmarksWithExcerpt(ctx context.Context, query string, limi
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 err := rows.Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.Public, &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)
}
diff --git a/internal/database_test.go b/internal/database_test.go
@@ -19,7 +19,7 @@ func TestMigrationsCRUDAndSearch(t *testing.T) {
defer db.Close()
// Create with archive content
- b, err := db.CreateBookmarkWithArchive(ctx, "http://example.local/1", "Example 1", nil, "The city of Durham has great food.")
+ b, err := db.CreateBookmarkWithArchive(ctx, "http://example.local/1", "Example 1", nil, false, "The city of Durham has great food.")
if err != nil {
t.Fatalf("create with archive: %v", err)
}
@@ -92,7 +92,7 @@ func TestFTSIndexUpdatesOnArchive(t *testing.T) {
}
defer db.Close()
- b, err := db.CreateBookmark(ctx, "http://no-archive.local", "No Archive", nil)
+ b, err := db.CreateBookmark(ctx, "http://no-archive.local", "No Archive", nil)
if err != nil {
t.Fatalf("create: %v", err)
}
diff --git a/internal/migrations/0005_public.sql b/internal/migrations/0005_public.sql
@@ -0,0 +1,3 @@
+-- Add public flag to bookmarks (0 = private, 1 = public)
+ALTER TABLE bookmarks ADD COLUMN public INTEGER NOT NULL DEFAULT 0;
+
diff --git a/internal/web/app.js b/internal/web/app.js
@@ -3,7 +3,7 @@ const { createApp } = Vue;
createApp({
data() {
return {
- form: { url: "", title: "", description: "" },
+ form: { url: "", title: "", description: "", public: false },
busy: false,
error: "",
message: "",
@@ -22,6 +22,7 @@ createApp({
b.editUrl = b.url;
b.editTitle = b.title;
b.editDescription = b.description || "";
+ b.editPublic = !!b.public;
b.saving = false;
},
cancelEdit(b) {
@@ -34,6 +35,7 @@ createApp({
url: b.editUrl,
title: b.editTitle,
description: b.editDescription,
+ public: !!b.editPublic,
};
const res = await fetch(`/bookmarks/${b.id}`, {
method: 'PUT',
@@ -71,6 +73,7 @@ createApp({
const payload = { url: this.form.url };
if (this.form.title) payload.title = this.form.title;
if (this.form.description) payload.description = this.form.description;
+ if (this.form.public) payload.public = true;
const res = await fetch("/bookmarks", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -85,6 +88,7 @@ createApp({
this.form.url = "";
this.form.title = "";
this.form.description = "";
+ this.form.public = false;
// refresh results (use current query)
this.search();
} catch (e) {
@@ -103,6 +107,7 @@ createApp({
this.results = arr.map((x) => ({
...x,
description: x.description ?? null,
+ public: !!x.public,
}));
} catch (e) {
console.error(e);
diff --git a/internal/web/index.html b/internal/web/index.html
@@ -37,6 +37,9 @@
Description (optional)
<textarea v-model.trim="form.description" rows="2" placeholder="Notes..."></textarea>
</label>
+ <label class="inline">
+ <input type="checkbox" v-model="form.public" /> Public
+ </label>
<button :disabled="busy">{{ busy ? 'Working…' : 'Save' }}</button>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="message" class="ok">{{ message }}</p>
@@ -50,7 +53,7 @@
<button :disabled="busySearch">{{ busySearch ? 'Searching…' : 'Search' }}</button>
</form>
<div class="results">
- <article v-for="b in results" :key="b.id" class="card">
+ <article v-for="b in results" :key="b.id" :class="['card', { private: !b.public }]">
<h3>
<a :href="b.url" target="_blank" rel="noopener noreferrer">{{ b.title || b.url }}</a>
</h3>
@@ -59,6 +62,7 @@
<label>URL <input v-model.trim="b.editUrl" type="url" required /></label>
<label>Title <input v-model.trim="b.editTitle" type="text" required /></label>
<label>Description <textarea v-model.trim="b.editDescription" rows="2"></textarea></label>
+ <label class="inline"><input type="checkbox" v-model="b.editPublic" /> Public</label>
<div class="actions">
<button @click="saveEdit(b)" :disabled="b.saving">{{ b.saving ? 'Saving…' : 'Save' }}</button>
<button @click="cancelEdit(b)">Cancel</button>
diff --git a/internal/web/styles.css b/internal/web/styles.css
@@ -17,12 +17,15 @@ h1 { margin: 0 0 4px 0; font-size: 24px; }
form { display: grid; gap: 10px; }
label { display: grid; gap: 6px; font-size: 13px; }
input, textarea { padding: 8px; font-size: 14px; border: 1px solid #ccc; border-radius: 6px; background: transparent; color: inherit; }
+label.inline { display: inline-flex; align-items: center; gap: 6px; }
button { padding: 8px 12px; font-size: 14px; border-radius: 6px; border: 1px solid #888; background: #eee; color: #111; cursor: pointer; }
@media (prefers-color-scheme: dark) { button { background: #333; color: #eee; border-color: #777; } }
.error { color: #b00020; }
.ok { color: #0a7f2e; }
.results { display: grid; gap: 12px; margin-top: 12px; }
.card { border: 1px solid #ddd3; border-radius: 8px; padding: 12px; }
+.card.private { background: #f6f7f8; }
+@media (prefers-color-scheme: dark) { .card.private { background: #202225; } }
.card h3 { margin: 0 0 6px 0; font-size: 16px; }
.archive-link { display:none; }
.icon { display:none; }