marky

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

commit 3ebc107aef28a6f1e402d707647a441d389c263c
parent aa6dfb6591b32c8ed6cf8c437968d0e6d27b051d
Author: David Eisinger <[email protected]>
Date:   Mon,  6 Oct 2025 15:50:39 -0400

Public/private

Diffstat:
Minternal/api.go | 151+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Minternal/database.go | 136++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Minternal/database_test.go | 4++--
Ainternal/migrations/0005_public.sql | 3+++
Minternal/web/app.js | 7++++++-
Minternal/web/index.html | 6+++++-
Minternal/web/styles.css | 3+++
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; }