marky

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

commit c4329c55cd755a82edcecf14ba5f08bc18f76fd6
parent 8928907e351e1e511ed35302fb0ef86ef48a170c
Author: David Eisinger <[email protected]>
Date:   Fri, 10 Oct 2025 10:33:41 -0400

Add referrer to API endpoints

Diffstat:
MREADME.md | 4+++-
Minternal/api.go | 113+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Minternal/database.go | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Minternal/user_db.go | 12++++++------
4 files changed, 137 insertions(+), 80 deletions(-)

diff --git a/README.md b/README.md @@ -73,7 +73,8 @@ Create a new bookmark. { "url": "https://example.com", // required "title": "Example", // optional; fetched from page <title> if missing - "description": "Optional description" // optional + "description": "Optional description", // optional + "referrer": "https://referrer.example" // optional; site that linked here } ``` - Responses: @@ -90,6 +91,7 @@ Create a new bookmark. "url": "https://example.com", "title": "Example", "description": "Optional description", + "referrer": "https://referrer.example", "created_at": "2025-01-01T12:34:56Z", "updated_at": "2025-01-01T12:34:56Z" } diff --git a/internal/api.go b/internal/api.go @@ -514,10 +514,12 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) { title = req.URL } } - pub := false - if req.Public != nil { - pub = *req.Public - } + 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) @@ -547,8 +549,13 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) { newPub = *req.Public } - // Apply metadata update first so archive reindex uses latest fields. - updated, err := db.UpdateBookmark(r.Context(), existing.ID, existing.URL, newTitle, newDesc, newPub) + // 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) @@ -567,20 +574,26 @@ 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 - } - 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: @@ -996,38 +1009,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"` + 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"` + 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 - } - return bookmarkResponse{ - ID: b.ID, - URL: b.URL, - Title: b.Title, - Description: desc, - CreatedAt: b.CreatedAt, - UpdatedAt: b.UpdatedAt, - Public: b.Public, - } + 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,14 +22,15 @@ var migrationsFS embed.FS // Bookmark represents a single bookmark row. type Bookmark struct { - ID int64 - URL string - Title string - Description 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. @@ -178,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)) @@ -194,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, @@ -252,9 +253,9 @@ 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, public, user_id, created_at, updated_at + SELECT id, url, title, description, referrer, public, user_id, created_at, updated_at FROM bookmarks WHERE id = ? - `, id).Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.Public, &b.UserID, &created, &updated); err != nil { + `, id).Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.Referrer, &b.Public, &b.UserID, &created, &updated); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -275,9 +276,9 @@ func (db *DB) GetBookmarkByURL(ctx context.Context, userID int64, u string) (*Bo var b Bookmark var created, updated string if err := db.sql.QueryRowContext(ctx, ` - SELECT id, url, title, description, public, user_id, created_at, updated_at + SELECT id, url, title, description, referrer, public, user_id, created_at, updated_at FROM bookmarks WHERE url = ? AND user_id = ? - `, u, userID).Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.Public, &b.UserID, &created, &updated); err != nil { + `, u, userID).Scan(&b.ID, &b.URL, &b.Title, &b.Description, &b.Referrer, &b.Public, &b.UserID, &created, &updated); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -299,7 +300,7 @@ func (db *DB) ListBookmarks(ctx context.Context, userID int64, limit, offset int limit = 50 } rows, err := db.sql.QueryContext(ctx, ` - SELECT id, url, title, description, public, user_id, created_at, updated_at + SELECT id, url, title, description, referrer, public, user_id, created_at, updated_at FROM bookmarks WHERE user_id = ? ORDER BY updated_at DESC @@ -312,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.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) } @@ -370,6 +371,39 @@ func (db *DB) UpdateBookmark(ctx context.Context, id int64, url, title string, d return db.GetBookmark(ctx, id) } +// 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, ` + 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) +} + // TouchBookmark updates only the updated_at timestamp. func (db *DB) TouchBookmark(ctx context.Context, id int64) error { _, err := db.sql.ExecContext(ctx, `UPDATE bookmarks SET updated_at = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339Nano), id) @@ -591,8 +625,8 @@ func (db *DB) SearchBookmarksWithExcerpt(ctx context.Context, userID int64, quer if limit <= 0 { limit = 50 } - rows, err := db.sql.QueryContext(ctx, ` - SELECT b.id, b.url, b.title, b.description, b.public, b.user_id, b.created_at, b.updated_at + 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 = ? @@ -606,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.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,8 +103,8 @@ func (db *DB) ListPublicBookmarks(ctx context.Context, userID int64, limit, offs if limit <= 0 { limit = 50 } - rows, err := db.sql.QueryContext(ctx, ` - SELECT id, url, title, description, public, user_id, created_at, updated_at + 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 LIMIT ? OFFSET ? @@ -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.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