marky

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

commit 6f0076bb326d556ce1c2e678ea0869e426aa8d85
parent c4329c55cd755a82edcecf14ba5f08bc18f76fd6
Author: David Eisinger <[email protected]>
Date:   Fri, 10 Oct 2025 10:35:23 -0400

Add referrer to form

Diffstat:
Minternal/api.go | 138+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Minternal/database.go | 94++++++++++++++++++++++++++++++++++++++++----------------------------------------
Minternal/user_db.go | 10+++++-----
Minternal/web/app.js | 4+++-
Minternal/web/index.html | 4++++
Minternal/web/user.html | 1+
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>