commit 6f0076bb326d556ce1c2e678ea0869e426aa8d85
parent c4329c55cd755a82edcecf14ba5f08bc18f76fd6
Author: David Eisinger <[email protected]>
Date: Fri, 10 Oct 2025 10:35:23 -0400
Add referrer to form
Diffstat:
6 files changed, 131 insertions(+), 120 deletions(-)
diff --git a/internal/api.go b/internal/api.go
@@ -514,12 +514,14 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) {
title = req.URL
}
}
- pub := false
- if req.Public != nil {
- pub = *req.Public
- }
- ref := ""
- if req.Referrer != nil { ref = *req.Referrer }
+ pub := false
+ if req.Public != nil {
+ pub = *req.Public
+ }
+ ref := ""
+ if req.Referrer != nil {
+ ref = *req.Referrer
+ }
if existing, err := db.GetBookmarkByURL(r.Context(), uid, req.URL); err != nil {
log.Printf("lookup by url error: %v", err)
@@ -549,13 +551,13 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) {
newPub = *req.Public
}
- // Apply metadata update first so archive reindex uses latest fields.
- // Include referrer when provided; otherwise preserve existing.
- newRef := existing.Referrer.String
- if req.Referrer != nil {
- newRef = *req.Referrer
- }
- updated, err := db.UpdateBookmarkWithReferrer(r.Context(), existing.ID, existing.URL, newTitle, newDesc, newPub, newRef)
+ // Apply metadata update first so archive reindex uses latest fields.
+ // Include referrer when provided; otherwise preserve existing.
+ newRef := existing.Referrer.String
+ if req.Referrer != nil {
+ newRef = *req.Referrer
+ }
+ updated, err := db.UpdateBookmarkWithReferrer(r.Context(), existing.ID, existing.URL, newTitle, newDesc, newPub, newRef)
if err != nil || updated == nil {
if err != nil {
log.Printf("update existing bookmark error: %v", err)
@@ -574,26 +576,28 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) {
_ = json.NewEncoder(w).Encode(toRespWithArchive(r.Context(), db, updated))
return
} else {
- var created *Bookmark
- if isHTML {
- created, err = db.CreateBookmarkWithArchive(r.Context(), uid, req.URL, title, req.Description, pub, txt)
- } else {
- created, err = db.CreateBookmarkNoArchive(r.Context(), uid, req.URL, title, req.Description, pub)
- }
- if err != nil {
- log.Printf("create bookmark with archive error: %v", err)
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- if strings.TrimSpace(ref) != "" {
- // Persist referrer via a focused update to avoid changing other fields
- _, _ = db.sql.ExecContext(r.Context(), `UPDATE bookmarks SET referrer = ? WHERE id = ?`, ref, created.ID)
- // Reload to include referrer in response
- if b2, _ := db.GetBookmark(r.Context(), created.ID); b2 != nil { created = b2 }
- }
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusCreated)
- _ = json.NewEncoder(w).Encode(toRespWithArchive(r.Context(), db, created))
+ var created *Bookmark
+ if isHTML {
+ created, err = db.CreateBookmarkWithArchive(r.Context(), uid, req.URL, title, req.Description, pub, txt)
+ } else {
+ created, err = db.CreateBookmarkNoArchive(r.Context(), uid, req.URL, title, req.Description, pub)
+ }
+ if err != nil {
+ log.Printf("create bookmark with archive error: %v", err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
+ if strings.TrimSpace(ref) != "" {
+ // Persist referrer via a focused update to avoid changing other fields
+ _, _ = db.sql.ExecContext(r.Context(), `UPDATE bookmarks SET referrer = ? WHERE id = ?`, ref, created.ID)
+ // Reload to include referrer in response
+ if b2, _ := db.GetBookmark(r.Context(), created.ID); b2 != nil {
+ created = b2
+ }
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ _ = json.NewEncoder(w).Encode(toRespWithArchive(r.Context(), db, created))
}
case http.MethodGet:
@@ -1009,46 +1013,46 @@ func isOwnerAuthorized(r *http.Request, u *User, db *DB) bool {
}
type createBookmarkRequest struct {
- URL string `json:"url"`
- Title string `json:"title"`
- Description *string `json:"description,omitempty"`
- Public *bool `json:"public,omitempty"`
- Referrer *string `json:"referrer,omitempty"`
+ URL string `json:"url"`
+ Title string `json:"title"`
+ Description *string `json:"description,omitempty"`
+ Public *bool `json:"public,omitempty"`
+ Referrer *string `json:"referrer,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"`
- Public bool `json:"public"`
- HasArchive bool `json:"has_archive"`
- Referrer *string `json:"referrer,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"`
+ HasArchive bool `json:"has_archive"`
+ Referrer *string `json:"referrer,omitempty"`
}
func toResp(b *Bookmark) bookmarkResponse {
- var desc *string
- if b.Description.Valid {
- desc = &b.Description.String
- }
- var ref *string
- if b.Referrer.Valid && strings.TrimSpace(b.Referrer.String) != "" {
- s := b.Referrer.String
- ref = &s
- }
- return bookmarkResponse{
- ID: b.ID,
- URL: b.URL,
- Title: b.Title,
- Description: desc,
- CreatedAt: b.CreatedAt,
- UpdatedAt: b.UpdatedAt,
- Public: b.Public,
- Referrer: ref,
- }
+ var desc *string
+ if b.Description.Valid {
+ desc = &b.Description.String
+ }
+ var ref *string
+ if b.Referrer.Valid && strings.TrimSpace(b.Referrer.String) != "" {
+ s := b.Referrer.String
+ ref = &s
+ }
+ return bookmarkResponse{
+ ID: b.ID,
+ URL: b.URL,
+ Title: b.Title,
+ Description: desc,
+ CreatedAt: b.CreatedAt,
+ UpdatedAt: b.UpdatedAt,
+ Public: b.Public,
+ Referrer: ref,
+ }
}
func toRespWithArchive(ctx context.Context, db *DB, b *Bookmark) bookmarkResponse {
diff --git a/internal/database.go b/internal/database.go
@@ -22,15 +22,15 @@ var migrationsFS embed.FS
// Bookmark represents a single bookmark row.
type Bookmark struct {
- ID int64
- URL string
- Title string
- Description sql.NullString
- Referrer sql.NullString
- Public bool
- UserID int64
- CreatedAt time.Time
- UpdatedAt time.Time
+ ID int64
+ URL string
+ Title string
+ Description sql.NullString
+ Referrer sql.NullString
+ Public bool
+ UserID int64
+ CreatedAt time.Time
+ UpdatedAt time.Time
}
// SearchResult is a bookmark match with an optional excerpt snippet.
@@ -179,7 +179,7 @@ func (db *DB) CreateBookmark(ctx context.Context, userID int64, url, title strin
if description != nil {
desc = sql.NullString{String: *description, Valid: true}
}
- res, err := db.sql.ExecContext(ctx, `
+ res, err := db.sql.ExecContext(ctx, `
INSERT INTO bookmarks(url, title, description, public, user_id, created_at, updated_at)
VALUES(?, ?, ?, 0, ?, ?, ?)
`, url, title, nullString(desc), userID, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
@@ -195,9 +195,9 @@ func (db *DB) CreateBookmark(ctx context.Context, userID int64, url, title strin
if c, _, ok, _ := db.GetArchive(ctx, id); ok {
ftsContent = c
}
- if err := ftsReplace(ctx, db.sql, id, title, url, nullString(desc), ftsContent); err != nil {
- return nil, fmt.Errorf("fts replace after create: %w", err)
- }
+ 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,
@@ -313,10 +313,10 @@ func (db *DB) ListBookmarks(ctx context.Context, userID int64, limit, offset int
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 bookmark: %w", err)
- }
+ 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 bookmark: %w", err)
+ }
if b.CreatedAt, err = time.Parse(time.RFC3339Nano, created); err != nil {
return nil, fmt.Errorf("parse created_at: %w", err)
}
@@ -373,35 +373,35 @@ func (db *DB) UpdateBookmark(ctx context.Context, id int64, url, title string, d
// UpdateBookmarkWithReferrer updates fields including referrer for a given bookmark.
func (db *DB) UpdateBookmarkWithReferrer(ctx context.Context, id int64, url, title string, description *string, public bool, referrer string) (*Bookmark, error) {
- if strings.TrimSpace(url) == "" || strings.TrimSpace(title) == "" {
- return nil, fmt.Errorf("url and title are required")
- }
- now := time.Now().UTC()
- var desc sql.NullString
- if description != nil {
- desc = sql.NullString{String: *description, Valid: true}
- }
- _, err := db.sql.ExecContext(ctx, `
+ if strings.TrimSpace(url) == "" || strings.TrimSpace(title) == "" {
+ return nil, fmt.Errorf("url and title are required")
+ }
+ now := time.Now().UTC()
+ var desc sql.NullString
+ if description != nil {
+ desc = sql.NullString{String: *description, Valid: true}
+ }
+ _, err := db.sql.ExecContext(ctx, `
UPDATE bookmarks
SET url = ?, title = ?, description = ?, public = ?, referrer = ?, updated_at = ?
WHERE id = ?
`, url, title, nullString(desc), publicToInt(public), referrer, now.Format(time.RFC3339Nano), id)
- if err != nil {
- return nil, fmt.Errorf("update bookmark with referrer: %w", err)
- }
- // Update FTS metadata while preserving content
- 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
- }
- if err := ftsReplace(ctx, db.sql, id, title, url, nullString(descForFTS), ftsContent); err != nil {
- return nil, fmt.Errorf("fts replace after update: %w", err)
- }
- return db.GetBookmark(ctx, id)
+ if err != nil {
+ return nil, fmt.Errorf("update bookmark with referrer: %w", err)
+ }
+ // Update FTS metadata while preserving content
+ 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
+ }
+ if err := ftsReplace(ctx, db.sql, id, title, url, nullString(descForFTS), ftsContent); err != nil {
+ return nil, fmt.Errorf("fts replace after update: %w", err)
+ }
+ return db.GetBookmark(ctx, id)
}
// TouchBookmark updates only the updated_at timestamp.
@@ -625,7 +625,7 @@ func (db *DB) SearchBookmarksWithExcerpt(ctx context.Context, userID int64, quer
if limit <= 0 {
limit = 50
}
- rows, err := db.sql.QueryContext(ctx, `
+ 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
@@ -640,10 +640,10 @@ func (db *DB) SearchBookmarksWithExcerpt(ctx context.Context, userID int64, quer
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 row: %w", err)
- }
+ 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 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/user_db.go b/internal/user_db.go
@@ -103,7 +103,7 @@ func (db *DB) ListPublicBookmarks(ctx context.Context, userID int64, limit, offs
if limit <= 0 {
limit = 50
}
- rows, err := db.sql.QueryContext(ctx, `
+ 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 = 1
ORDER BY updated_at DESC
@@ -116,10 +116,10 @@ func (db *DB) ListPublicBookmarks(ctx context.Context, userID int64, limit, offs
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 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
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: "", public: false },
+ form: { url: "", title: "", description: "", referrer: "", public: false },
busy: false,
error: "",
message: "",
@@ -176,6 +176,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.referrer) payload.referrer = this.form.referrer;
if (this.form.public) payload.public = true;
const res = await fetch("/bookmarks", {
method: "POST",
@@ -191,6 +192,7 @@ createApp({
this.form.url = "";
this.form.title = "";
this.form.description = "";
+ this.form.referrer = "";
this.form.public = false;
// refresh results (use current query)
this.search();
diff --git a/internal/web/index.html b/internal/web/index.html
@@ -58,6 +58,10 @@
Description (optional)
<textarea v-model.trim="form.description" rows="2" placeholder="Notes..."></textarea>
</label>
+ <label>
+ Referrer (optional)
+ <input v-model.trim="form.referrer" type="url" placeholder="https://site-that-linked-here.example" />
+ </label>
<label class="inline">
<input type="checkbox" v-model="form.public" /> Public
</label>
diff --git a/internal/web/user.html b/internal/web/user.html
@@ -23,6 +23,7 @@
<label>URL <input v-model.trim="form.url" type="url" required placeholder="https://example.com" /></label>
<label>Title (optional) <input v-model.trim="form.title" type="text" placeholder="Page title" /></label>
<label>Description (optional) <textarea v-model.trim="form.description" rows="2" placeholder="Notes..."></textarea></label>
+ <label>Referrer (optional) <input v-model.trim="form.referrer" type="url" placeholder="https://site-that-linked-here.example" /></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>