feat: add notes field to favorites and enhance OG meta tags

Add an optional long-form "notes" text field to each favorite for
reviews, thoughts, or extended descriptions. The field is stored in
SQLite via a new migration (002_add_fave_notes.sql) and propagated
through the entire stack:

- Model: Notes field on Fave struct
- Store: All SQL queries (Create, GetByID, Update, list methods,
  scanFaves) updated with notes column
- Web handlers: Read/write notes in create, edit, update forms
- API handlers: Notes in create, update, get, import request/response
- Export: Notes included in both JSON and CSV exports
- Import: Notes parsed from both JSON and CSV imports
- Feed: Notes used as Atom feed item summary when present
- Form template: New textarea between URL and image fields
- Detail template: Display notes, enhanced og:description with
  cascade: notes (truncated) → URL → generic fallback text

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-04-04 00:40:08 +02:00
commit 485d01ce45
14 changed files with 151 additions and 71 deletions

View file

@ -0,0 +1,2 @@
-- Legger til et valgfritt notatfelt på favoritter.
ALTER TABLE faves ADD COLUMN notes TEXT NOT NULL DEFAULT '';

View file

@ -135,6 +135,7 @@ func (h *Handler) handleCreateFave(w http.ResponseWriter, r *http.Request) {
var req struct {
Description string `json:"description"`
URL string `json:"url"`
Notes string `json:"notes"`
Privacy string `json:"privacy"`
Tags []string `json:"tags"`
}
@ -151,7 +152,7 @@ func (h *Handler) handleCreateFave(w http.ResponseWriter, r *http.Request) {
req.Privacy = user.DefaultFavePrivacy
}
fave, err := h.deps.Faves.Create(user.ID, req.Description, req.URL, "", req.Privacy)
fave, err := h.deps.Faves.Create(user.ID, req.Description, req.URL, "", req.Notes, req.Privacy)
if err != nil {
slog.Error("api: create fave error", "error", err)
jsonError(w, "Internal error", http.StatusInternalServerError)
@ -222,6 +223,7 @@ func (h *Handler) handleUpdateFave(w http.ResponseWriter, r *http.Request) {
var req struct {
Description string `json:"description"`
URL string `json:"url"`
Notes string `json:"notes"`
Privacy string `json:"privacy"`
Tags []string `json:"tags"`
}
@ -237,7 +239,7 @@ func (h *Handler) handleUpdateFave(w http.ResponseWriter, r *http.Request) {
req.Privacy = fave.Privacy
}
if err := h.deps.Faves.Update(id, req.Description, req.URL, fave.ImagePath, req.Privacy); err != nil {
if err := h.deps.Faves.Update(id, req.Description, req.URL, fave.ImagePath, req.Notes, req.Privacy); err != nil {
slog.Error("api: update fave error", "error", err)
jsonError(w, "Internal error", http.StatusInternalServerError)
return
@ -378,6 +380,7 @@ func (h *Handler) handleImport(w http.ResponseWriter, r *http.Request) {
var faves []struct {
Description string `json:"description"`
URL string `json:"url"`
Notes string `json:"notes"`
Privacy string `json:"privacy"`
Tags []string `json:"tags"`
}
@ -395,7 +398,7 @@ func (h *Handler) handleImport(w http.ResponseWriter, r *http.Request) {
if privacy != "public" && privacy != "private" {
privacy = user.DefaultFavePrivacy
}
fave, err := h.deps.Faves.Create(user.ID, f.Description, f.URL, "", privacy)
fave, err := h.deps.Faves.Create(user.ID, f.Description, f.URL, "", f.Notes, privacy)
if err != nil {
continue
}
@ -446,6 +449,7 @@ func faveJSON(f *model.Fave) map[string]any {
"id": f.ID,
"description": f.Description,
"url": f.URL,
"notes": f.Notes,
"image_path": f.ImagePath,
"privacy": f.Privacy,
"tags": tags,

View file

@ -222,7 +222,7 @@ func TestAPIGetFave(t *testing.T) {
// Create a public fave directly.
user, _ := users.GetByUsername("testuser")
fave, _ := h.deps.Faves.Create(user.ID, "Test fave", "https://example.com", "", "public")
fave, _ := h.deps.Faves.Create(user.ID, "Test fave", "https://example.com", "", "", "public")
h.deps.Tags.SetFaveTags(fave.ID, []string{"test"})
req := httptest.NewRequest("GET", "/api/v1/faves/"+faveIDStr(fave.ID), nil)
@ -257,7 +257,7 @@ func TestAPIPrivateFaveHiddenFromOthers(t *testing.T) {
// User A creates a private fave.
userA, _ := users.Create("usera", "pass123", "user")
fave, _ := h.deps.Faves.Create(userA.ID, "Secret", "", "", "private")
fave, _ := h.deps.Faves.Create(userA.ID, "Secret", "", "", "", "private")
// User B tries to access it.
cookieB := apiLogin(t, users, sessions, "userb", "pass123", "user")
@ -276,7 +276,7 @@ func TestAPIPrivateFaveVisibleToOwner(t *testing.T) {
h, mux, users, sessions := testAPIServer(t)
userA, _ := users.Create("usera", "pass123", "user")
fave, _ := h.deps.Faves.Create(userA.ID, "My secret", "", "", "private")
fave, _ := h.deps.Faves.Create(userA.ID, "My secret", "", "", "", "private")
tokenA, _ := sessions.Create(userA.ID)
cookieA := &http.Cookie{Name: "session", Value: tokenA}
@ -295,7 +295,7 @@ func TestAPIUpdateFave(t *testing.T) {
h, mux, users, sessions := testAPIServer(t)
user, _ := users.Create("testuser", "pass123", "user")
fave, _ := h.deps.Faves.Create(user.ID, "Original", "https://old.com", "", "public")
fave, _ := h.deps.Faves.Create(user.ID, "Original", "https://old.com", "", "", "public")
token, _ := sessions.Create(user.ID)
cookie := &http.Cookie{Name: "session", Value: token}
@ -322,7 +322,7 @@ func TestAPIUpdateFaveNotOwner(t *testing.T) {
h, mux, users, sessions := testAPIServer(t)
userA, _ := users.Create("usera", "pass123", "user")
fave, _ := h.deps.Faves.Create(userA.ID, "A's fave", "", "", "public")
fave, _ := h.deps.Faves.Create(userA.ID, "A's fave", "", "", "", "public")
cookieB := apiLogin(t, users, sessions, "userb", "pass123", "user")
@ -341,7 +341,7 @@ func TestAPIDeleteFave(t *testing.T) {
h, mux, users, sessions := testAPIServer(t)
user, _ := users.Create("testuser", "pass123", "user")
fave, _ := h.deps.Faves.Create(user.ID, "Delete me", "", "", "public")
fave, _ := h.deps.Faves.Create(user.ID, "Delete me", "", "", "", "public")
token, _ := sessions.Create(user.ID)
cookie := &http.Cookie{Name: "session", Value: token}
@ -368,7 +368,7 @@ func TestAPIDeleteFaveNotOwner(t *testing.T) {
h, mux, users, sessions := testAPIServer(t)
userA, _ := users.Create("usera", "pass123", "user")
fave, _ := h.deps.Faves.Create(userA.ID, "A's fave", "", "", "public")
fave, _ := h.deps.Faves.Create(userA.ID, "A's fave", "", "", "", "public")
cookieB := apiLogin(t, users, sessions, "userb", "pass123", "user")
@ -386,9 +386,9 @@ func TestAPIListFaves(t *testing.T) {
h, mux, users, sessions := testAPIServer(t)
user, _ := users.Create("testuser", "pass123", "user")
h.deps.Faves.Create(user.ID, "Fave 1", "", "", "public")
h.deps.Faves.Create(user.ID, "Fave 2", "", "", "public")
h.deps.Faves.Create(user.ID, "Fave 3", "", "", "private")
h.deps.Faves.Create(user.ID, "Fave 1", "", "", "", "public")
h.deps.Faves.Create(user.ID, "Fave 2", "", "", "", "public")
h.deps.Faves.Create(user.ID, "Fave 3", "", "", "", "private")
token, _ := sessions.Create(user.ID)
cookie := &http.Cookie{Name: "session", Value: token}
@ -413,7 +413,7 @@ func TestAPIListFavesPagination(t *testing.T) {
user, _ := users.Create("testuser", "pass123", "user")
for i := 0; i < 5; i++ {
h.deps.Faves.Create(user.ID, "Fave", "", "", "public")
h.deps.Faves.Create(user.ID, "Fave", "", "", "", "public")
}
token, _ := sessions.Create(user.ID)
cookie := &http.Cookie{Name: "session", Value: token}
@ -443,7 +443,7 @@ func TestAPISearchTags(t *testing.T) {
h, mux, users, _ := testAPIServer(t)
user, _ := users.Create("testuser", "pass123", "user")
fave, _ := h.deps.Faves.Create(user.ID, "Test", "", "", "public")
fave, _ := h.deps.Faves.Create(user.ID, "Test", "", "", "", "public")
h.deps.Tags.SetFaveTags(fave.ID, []string{"golang", "goroutines", "python"})
req := httptest.NewRequest("GET", "/api/v1/tags?q=go", nil)
@ -531,8 +531,8 @@ func TestAPIGetDisabledUser(t *testing.T) {
func TestAPIGetUserFaves(t *testing.T) {
h, mux, users, _ := testAPIServer(t)
user, _ := users.Create("testuser", "pass123", "user")
h.deps.Faves.Create(user.ID, "Public fave", "", "", "public")
h.deps.Faves.Create(user.ID, "Private fave", "", "", "private")
h.deps.Faves.Create(user.ID, "Public fave", "", "", "", "public")
h.deps.Faves.Create(user.ID, "Private fave", "", "", "", "private")
req := httptest.NewRequest("GET", "/api/v1/users/testuser/faves", nil)
rr := httptest.NewRecorder()
@ -555,8 +555,8 @@ func TestAPIExport(t *testing.T) {
h, mux, users, sessions := testAPIServer(t)
user, _ := users.Create("testuser", "pass123", "user")
h.deps.Faves.Create(user.ID, "Fave 1", "", "", "public")
h.deps.Faves.Create(user.ID, "Fave 2", "", "", "private")
h.deps.Faves.Create(user.ID, "Fave 1", "", "", "", "public")
h.deps.Faves.Create(user.ID, "Fave 2", "", "", "", "private")
token, _ := sessions.Create(user.ID)
cookie := &http.Cookie{Name: "session", Value: token}

View file

@ -72,12 +72,13 @@ func (h *Handler) handleFaveCreate(w http.ResponseWriter, r *http.Request) {
description := strings.TrimSpace(r.FormValue("description"))
url := strings.TrimSpace(r.FormValue("url"))
notes := strings.TrimSpace(r.FormValue("notes"))
privacy := r.FormValue("privacy")
tagStr := r.FormValue("tags")
if description == "" {
h.flash(w, r, "fave_form", "Beskrivelse er påkrevd.", "error", map[string]any{
"IsNew": true, "Description": description, "URL": url, "Tags": tagStr, "Privacy": privacy,
"IsNew": true, "Description": description, "URL": url, "Notes": notes, "Tags": tagStr, "Privacy": privacy,
})
return
}
@ -95,7 +96,7 @@ func (h *Handler) handleFaveCreate(w http.ResponseWriter, r *http.Request) {
if err != nil {
slog.Error("image process error", "error", err)
h.flash(w, r, "fave_form", "Kunne ikke behandle bildet. Sjekk at filen er et gyldig bilde (JPEG, PNG, GIF eller WebP).", "error", map[string]any{
"IsNew": true, "Description": description, "URL": url, "Tags": tagStr, "Privacy": privacy,
"IsNew": true, "Description": description, "URL": url, "Notes": notes, "Tags": tagStr, "Privacy": privacy,
})
return
}
@ -103,7 +104,7 @@ func (h *Handler) handleFaveCreate(w http.ResponseWriter, r *http.Request) {
}
// Create the fave.
fave, err := h.deps.Faves.Create(user.ID, description, url, imagePath, privacy)
fave, err := h.deps.Faves.Create(user.ID, description, url, imagePath, notes, privacy)
if err != nil {
slog.Error("create fave error", "error", err)
h.flash(w, r, "fave_form", "Noe gikk galt. Prøv igjen.", "error", map[string]any{"IsNew": true})
@ -205,6 +206,7 @@ func (h *Handler) handleFaveEdit(w http.ResponseWriter, r *http.Request) {
"Fave": fave,
"Description": fave.Description,
"URL": fave.URL,
"Notes": fave.Notes,
"Privacy": fave.Privacy,
"Tags": strings.Join(tagNames, ", "),
},
@ -244,6 +246,7 @@ func (h *Handler) handleFaveUpdate(w http.ResponseWriter, r *http.Request) {
description := strings.TrimSpace(r.FormValue("description"))
url := strings.TrimSpace(r.FormValue("url"))
notes := strings.TrimSpace(r.FormValue("notes"))
privacy := r.FormValue("privacy")
tagStr := r.FormValue("tags")
@ -283,7 +286,7 @@ func (h *Handler) handleFaveUpdate(w http.ResponseWriter, r *http.Request) {
imagePath = ""
}
if err := h.deps.Faves.Update(id, description, url, imagePath, privacy); err != nil {
if err := h.deps.Faves.Update(id, description, url, imagePath, notes, privacy); err != nil {
slog.Error("update fave error", "error", err)
h.flash(w, r, "fave_form", "Noe gikk galt. Prøv igjen.", "error", map[string]any{"IsNew": false, "Fave": fave})
return

View file

@ -129,6 +129,10 @@ func favesToFeedItems(faves []*model.Fave, baseURL string) []*feeds.Item {
Updated: f.UpdatedAt,
}
if f.Notes != "" {
item.Description = f.Notes
}
if f.URL != "" {
escaped := html.EscapeString(f.URL)
item.Content = `<p><a href="` + escaped + `">` + escaped + `</a></p>`

View file

@ -220,7 +220,7 @@ func TestPrivateFaveHiddenFromOthers(t *testing.T) {
// User A creates a private fave.
userA, _ := h.deps.Users.Create("usera", "pass123", "user")
fave, _ := h.deps.Faves.Create(userA.ID, "Secret fave", "", "", "private")
fave, _ := h.deps.Faves.Create(userA.ID, "Secret fave", "", "", "", "private")
// User B tries to view it.
cookieB := loginUser(t, h, "userb", "pass123", "user")
@ -239,7 +239,7 @@ func TestPrivateFaveVisibleToOwner(t *testing.T) {
h, mux := testServer(t)
userA, _ := h.deps.Users.Create("usera", "pass123", "user")
fave, _ := h.deps.Faves.Create(userA.ID, "My secret", "", "", "private")
fave, _ := h.deps.Faves.Create(userA.ID, "My secret", "", "", "", "private")
tokenA, _ := h.deps.Sessions.Create(userA.ID)
cookieA := &http.Cookie{Name: "session", Value: tokenA}
@ -290,7 +290,7 @@ func TestTagSearchEndpoint(t *testing.T) {
// Create some tags via faves.
user, _ := h.deps.Users.Create("testuser", "pass123", "user")
fave, _ := h.deps.Faves.Create(user.ID, "Test", "", "", "public")
fave, _ := h.deps.Faves.Create(user.ID, "Test", "", "", "", "public")
h.deps.Tags.SetFaveTags(fave.ID, []string{"music", "movies", "manga"})
req := httptest.NewRequest("GET", "/tags/search?q=mu", nil)
@ -310,7 +310,7 @@ func TestFeedGlobal(t *testing.T) {
h, mux := testServer(t)
user, _ := h.deps.Users.Create("testuser", "pass123", "user")
h.deps.Faves.Create(user.ID, "Public fave", "", "", "public")
h.deps.Faves.Create(user.ID, "Public fave", "", "", "", "public")
req := httptest.NewRequest("GET", "/feed.xml", nil)
rr := httptest.NewRecorder()

View file

@ -21,6 +21,7 @@ const maxExportFaves = 100000
type ExportFave struct {
Description string `json:"description"`
URL string `json:"url,omitempty"`
Notes string `json:"notes,omitempty"`
Privacy string `json:"privacy"`
Tags []string `json:"tags,omitempty"`
CreatedAt string `json:"created_at"`
@ -57,6 +58,7 @@ func (h *Handler) handleExportJSON(w http.ResponseWriter, r *http.Request) {
export[i] = ExportFave{
Description: f.Description,
URL: f.URL,
Notes: f.Notes,
Privacy: f.Privacy,
Tags: tags,
CreatedAt: f.CreatedAt.Format("2006-01-02T15:04:05Z"),
@ -89,7 +91,7 @@ func (h *Handler) handleExportCSV(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Disposition", "attachment; filename=favoritter.csv")
cw := csv.NewWriter(w)
cw.Write([]string{"description", "url", "privacy", "tags", "created_at"})
cw.Write([]string{"description", "url", "notes", "privacy", "tags", "created_at"})
for _, f := range faves {
tags := make([]string, len(f.Tags))
@ -99,6 +101,7 @@ func (h *Handler) handleExportCSV(w http.ResponseWriter, r *http.Request) {
cw.Write([]string{
f.Description,
f.URL,
f.Notes,
f.Privacy,
strings.Join(tags, ","),
f.CreatedAt.Format("2006-01-02T15:04:05Z"),
@ -159,7 +162,7 @@ func (h *Handler) handleImportPost(w http.ResponseWriter, r *http.Request) {
privacy = user.DefaultFavePrivacy
}
fave, err := h.deps.Faves.Create(user.ID, ef.Description, ef.URL, "", privacy)
fave, err := h.deps.Faves.Create(user.ID, ef.Description, ef.URL, "", ef.Notes, privacy)
if err != nil {
slog.Error("import: create fave error", "error", err)
continue
@ -213,6 +216,9 @@ func parseImportCSV(r io.Reader) ([]ExportFave, error) {
if idx, ok := colMap["url"]; ok && idx < len(row) {
f.URL = row[idx]
}
if idx, ok := colMap["notes"]; ok && idx < len(row) {
f.Notes = row[idx]
}
if idx, ok := colMap["privacy"]; ok && idx < len(row) {
f.Privacy = row[idx]
}

View file

@ -364,7 +364,7 @@ func TestEditFaveNotOwner(t *testing.T) {
h, mux := testServer(t)
userA, _ := h.deps.Users.Create("usera", "pass123", "user")
fave, _ := h.deps.Faves.Create(userA.ID, "A's fave", "", "", "public")
fave, _ := h.deps.Faves.Create(userA.ID, "A's fave", "", "", "", "public")
cookieB := loginUser(t, h, "userb", "pass123", "user")
@ -382,7 +382,7 @@ func TestDeleteFaveHTMX(t *testing.T) {
h, mux := testServer(t)
user, _ := h.deps.Users.Create("testuser", "pass123", "user")
fave, _ := h.deps.Faves.Create(user.ID, "Delete me", "", "", "public")
fave, _ := h.deps.Faves.Create(user.ID, "Delete me", "", "", "", "public")
token, _ := h.deps.Sessions.Create(user.ID)
cookie := &http.Cookie{Name: "session", Value: token}
@ -409,7 +409,7 @@ func TestDeleteFaveNotOwner(t *testing.T) {
h, mux := testServer(t)
userA, _ := h.deps.Users.Create("usera", "pass123", "user")
fave, _ := h.deps.Faves.Create(userA.ID, "A's fave", "", "", "public")
fave, _ := h.deps.Faves.Create(userA.ID, "A's fave", "", "", "", "public")
cookieB := loginUser(t, h, "userb", "pass123", "user")
@ -623,7 +623,7 @@ func TestAdminTags(t *testing.T) {
// Create a tag via a fave.
admin, _ := h.deps.Users.GetByUsername("admin")
fave, _ := h.deps.Faves.Create(admin.ID, "Test", "", "", "public")
fave, _ := h.deps.Faves.Create(admin.ID, "Test", "", "", "", "public")
h.deps.Tags.SetFaveTags(fave.ID, []string{"testmerke"})
req := httptest.NewRequest("GET", "/admin/tags", nil)
@ -646,7 +646,7 @@ func TestUserFeed(t *testing.T) {
user, _ := h.deps.Users.Create("feeduser", "pass123", "user")
h.deps.Users.UpdateProfile(user.ID, "Feed User", "", "public", "public")
h.deps.Faves.Create(user.ID, "User fave", "", "", "public")
h.deps.Faves.Create(user.ID, "User fave", "", "", "", "public")
req := httptest.NewRequest("GET", "/u/feeduser/feed.xml", nil)
rr := httptest.NewRecorder()
@ -668,8 +668,8 @@ func TestFeedExcludesPrivate(t *testing.T) {
h, mux := testServer(t)
user, _ := h.deps.Users.Create("testuser", "pass123", "user")
h.deps.Faves.Create(user.ID, "Public fave", "", "", "public")
h.deps.Faves.Create(user.ID, "Secret fave", "", "", "private")
h.deps.Faves.Create(user.ID, "Public fave", "", "", "", "public")
h.deps.Faves.Create(user.ID, "Secret fave", "", "", "", "private")
req := httptest.NewRequest("GET", "/feed.xml", nil)
rr := httptest.NewRecorder()
@ -688,7 +688,7 @@ func TestTagFeed(t *testing.T) {
h, mux := testServer(t)
user, _ := h.deps.Users.Create("testuser", "pass123", "user")
fave, _ := h.deps.Faves.Create(user.ID, "Tagged fave", "", "", "public")
fave, _ := h.deps.Faves.Create(user.ID, "Tagged fave", "", "", "", "public")
h.deps.Tags.SetFaveTags(fave.ID, []string{"golang"})
req := httptest.NewRequest("GET", "/tags/golang/feed.xml", nil)
@ -725,7 +725,7 @@ func TestExportJSON(t *testing.T) {
cookie := loginUser(t, h, "testuser", "pass123", "user")
user, _ := h.deps.Users.GetByUsername("testuser")
fave, _ := h.deps.Faves.Create(user.ID, "Export me", "https://example.com", "", "public")
fave, _ := h.deps.Faves.Create(user.ID, "Export me", "https://example.com", "", "", "public")
h.deps.Tags.SetFaveTags(fave.ID, []string{"test"})
req := httptest.NewRequest("GET", "/export/json", nil)
@ -759,7 +759,7 @@ func TestExportCSV(t *testing.T) {
cookie := loginUser(t, h, "testuser", "pass123", "user")
user, _ := h.deps.Users.GetByUsername("testuser")
h.deps.Faves.Create(user.ID, "CSV fave", "", "", "public")
h.deps.Faves.Create(user.ID, "CSV fave", "", "", "", "public")
req := httptest.NewRequest("GET", "/export/csv", nil)
req.AddCookie(cookie)
@ -871,7 +871,7 @@ func TestProfileOwnerSeesPrivateFaves(t *testing.T) {
h, mux := testServer(t)
user, _ := h.deps.Users.Create("owner", "pass123", "user")
h.deps.Faves.Create(user.ID, "Private fave", "", "", "private")
h.deps.Faves.Create(user.ID, "Private fave", "", "", "", "private")
token, _ := h.deps.Sessions.Create(user.ID)
cookie := &http.Cookie{Name: "session", Value: token}
@ -893,8 +893,8 @@ func TestProfileVisitorCannotSeePrivateFaves(t *testing.T) {
user, _ := h.deps.Users.Create("owner", "pass123", "user")
h.deps.Users.UpdateProfile(user.ID, "Owner", "", "public", "public")
h.deps.Faves.Create(user.ID, "Only public", "", "", "public")
h.deps.Faves.Create(user.ID, "Hidden secret", "", "", "private")
h.deps.Faves.Create(user.ID, "Only public", "", "", "", "public")
h.deps.Faves.Create(user.ID, "Hidden secret", "", "", "", "private")
req := httptest.NewRequest("GET", "/u/owner", nil)
rr := httptest.NewRecorder()
@ -915,7 +915,7 @@ func TestTagBrowse(t *testing.T) {
h, mux := testServer(t)
user, _ := h.deps.Users.Create("testuser", "pass123", "user")
fave, _ := h.deps.Faves.Create(user.ID, "Tagged", "", "", "public")
fave, _ := h.deps.Faves.Create(user.ID, "Tagged", "", "", "", "public")
h.deps.Tags.SetFaveTags(fave.ID, []string{"golang"})
req := httptest.NewRequest("GET", "/tags/golang", nil)
@ -949,7 +949,7 @@ func TestHomePageAuthenticated(t *testing.T) {
cookie := loginUser(t, h, "testuser", "pass123", "user")
user, _ := h.deps.Users.GetByUsername("testuser")
h.deps.Faves.Create(user.ID, "Home fave", "", "", "public")
h.deps.Faves.Create(user.ID, "Home fave", "", "", "", "public")
req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(cookie)
@ -1074,7 +1074,7 @@ func TestTagSuggestionsNoInlineHandlers(t *testing.T) {
h, mux := testServer(t)
user, _ := h.deps.Users.Create("testuser", "pass123", "user")
fave, _ := h.deps.Faves.Create(user.ID, "Test", "", "", "public")
fave, _ := h.deps.Faves.Create(user.ID, "Test", "", "", "", "public")
h.deps.Tags.SetFaveTags(fave.ID, []string{"golang", "goroutines"})
req := httptest.NewRequest("GET", "/tags/search?q=go", nil)
@ -1117,7 +1117,7 @@ func TestDisplayNameFallbackToUsername(t *testing.T) {
// Create a user WITHOUT a display name and a public fave.
user, _ := h.deps.Users.GetByUsername("testuser")
h.deps.Faves.Create(user.ID, "Test fave", "", "", "public")
h.deps.Faves.Create(user.ID, "Test fave", "", "", "", "public")
// The home page shows "av <display_name>" — with no display name set,
// it should fall back to the username.

View file

@ -10,6 +10,7 @@ type Fave struct {
Description string
URL string
ImagePath string
Notes string
Privacy string
Tags []Tag
CreatedAt time.Time

View file

@ -23,11 +23,11 @@ func NewFaveStore(db *sql.DB) *FaveStore {
}
// Create inserts a new fave and returns it with its ID populated.
func (s *FaveStore) Create(userID int64, description, url, imagePath, privacy string) (*model.Fave, error) {
func (s *FaveStore) Create(userID int64, description, url, imagePath, notes, privacy string) (*model.Fave, error) {
result, err := s.db.Exec(
`INSERT INTO faves (user_id, description, url, image_path, privacy)
VALUES (?, ?, ?, ?, ?)`,
userID, description, url, imagePath, privacy,
`INSERT INTO faves (user_id, description, url, image_path, notes, privacy)
VALUES (?, ?, ?, ?, ?, ?)`,
userID, description, url, imagePath, notes, privacy,
)
if err != nil {
return nil, fmt.Errorf("insert fave: %w", err)
@ -42,13 +42,13 @@ func (s *FaveStore) GetByID(id int64) (*model.Fave, error) {
f := &model.Fave{}
var createdAt, updatedAt string
err := s.db.QueryRow(
`SELECT f.id, f.user_id, f.description, f.url, f.image_path, f.privacy,
`SELECT f.id, f.user_id, f.description, f.url, f.image_path, f.notes, f.privacy,
f.created_at, f.updated_at, u.username, COALESCE(NULLIF(u.display_name, ''), u.username)
FROM faves f
JOIN users u ON u.id = f.user_id
WHERE f.id = ?`, id,
).Scan(
&f.ID, &f.UserID, &f.Description, &f.URL, &f.ImagePath, &f.Privacy,
&f.ID, &f.UserID, &f.Description, &f.URL, &f.ImagePath, &f.Notes, &f.Privacy,
&createdAt, &updatedAt, &f.Username, &f.DisplayName,
)
if errors.Is(err, sql.ErrNoRows) {
@ -63,12 +63,12 @@ func (s *FaveStore) GetByID(id int64) (*model.Fave, error) {
}
// Update modifies an existing fave's fields.
func (s *FaveStore) Update(id int64, description, url, imagePath, privacy string) error {
func (s *FaveStore) Update(id int64, description, url, imagePath, notes, privacy string) error {
_, err := s.db.Exec(
`UPDATE faves SET description = ?, url = ?, image_path = ?, privacy = ?,
`UPDATE faves SET description = ?, url = ?, image_path = ?, notes = ?, privacy = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = ?`,
description, url, imagePath, privacy, id,
description, url, imagePath, notes, privacy, id,
)
return err
}
@ -96,7 +96,7 @@ func (s *FaveStore) ListByUser(userID int64, limit, offset int) ([]*model.Fave,
}
rows, err := s.db.Query(
`SELECT f.id, f.user_id, f.description, f.url, f.image_path, f.privacy,
`SELECT f.id, f.user_id, f.description, f.url, f.image_path, f.notes, f.privacy,
f.created_at, f.updated_at, u.username, COALESCE(NULLIF(u.display_name, ''), u.username)
FROM faves f
JOIN users u ON u.id = f.user_id
@ -125,7 +125,7 @@ func (s *FaveStore) ListPublicByUser(userID int64, limit, offset int) ([]*model.
}
rows, err := s.db.Query(
`SELECT f.id, f.user_id, f.description, f.url, f.image_path, f.privacy,
`SELECT f.id, f.user_id, f.description, f.url, f.image_path, f.notes, f.privacy,
f.created_at, f.updated_at, u.username, COALESCE(NULLIF(u.display_name, ''), u.username)
FROM faves f
JOIN users u ON u.id = f.user_id
@ -152,7 +152,7 @@ func (s *FaveStore) ListPublic(limit, offset int) ([]*model.Fave, int, error) {
}
rows, err := s.db.Query(
`SELECT f.id, f.user_id, f.description, f.url, f.image_path, f.privacy,
`SELECT f.id, f.user_id, f.description, f.url, f.image_path, f.notes, f.privacy,
f.created_at, f.updated_at, u.username, COALESCE(NULLIF(u.display_name, ''), u.username)
FROM faves f
JOIN users u ON u.id = f.user_id
@ -184,7 +184,7 @@ func (s *FaveStore) ListByTag(tagName string, limit, offset int) ([]*model.Fave,
}
rows, err := s.db.Query(
`SELECT f.id, f.user_id, f.description, f.url, f.image_path, f.privacy,
`SELECT f.id, f.user_id, f.description, f.url, f.image_path, f.notes, f.privacy,
f.created_at, f.updated_at, u.username, COALESCE(NULLIF(u.display_name, ''), u.username)
FROM faves f
JOIN users u ON u.id = f.user_id
@ -261,7 +261,7 @@ func (s *FaveStore) scanFaves(rows *sql.Rows) ([]*model.Fave, error) {
f := &model.Fave{}
var createdAt, updatedAt string
err := rows.Scan(
&f.ID, &f.UserID, &f.Description, &f.URL, &f.ImagePath, &f.Privacy,
&f.ID, &f.UserID, &f.Description, &f.URL, &f.ImagePath, &f.Notes, &f.Privacy,
&createdAt, &updatedAt, &f.Username, &f.DisplayName,
)
if err != nil {

View file

@ -23,7 +23,7 @@ func TestFaveCRUD(t *testing.T) {
}
// Create a fave.
fave, err := faves.Create(user.ID, "Blade Runner 2049", "https://example.com", "", "public")
fave, err := faves.Create(user.ID, "Blade Runner 2049", "https://example.com", "", "", "public")
if err != nil {
t.Fatalf("create fave: %v", err)
}
@ -44,7 +44,7 @@ func TestFaveCRUD(t *testing.T) {
}
// Update.
err = faves.Update(fave.ID, "Blade Runner 2049 (Final Cut)", "https://example.com/br2049", "", "private")
err = faves.Update(fave.ID, "Blade Runner 2049 (Final Cut)", "https://example.com/br2049", "", "", "private")
if err != nil {
t.Fatalf("update fave: %v", err)
}
@ -107,6 +107,49 @@ func TestFaveCRUD(t *testing.T) {
}
}
func TestFaveNotes(t *testing.T) {
db := testDB(t)
users := NewUserStore(db)
faves := NewFaveStore(db)
Argon2Memory = 1024
Argon2Time = 1
defer func() { Argon2Memory = 65536; Argon2Time = 3 }()
user, _ := users.Create("testuser", "password123", "user")
// Create with notes.
fave, err := faves.Create(user.ID, "Film", "https://example.com", "", "En fantastisk film", "public")
if err != nil {
t.Fatalf("create fave with notes: %v", err)
}
if fave.Notes != "En fantastisk film" {
t.Errorf("notes = %q, want %q", fave.Notes, "En fantastisk film")
}
// Update notes.
err = faves.Update(fave.ID, fave.Description, fave.URL, fave.ImagePath, "Oppdatert anmeldelse", fave.Privacy)
if err != nil {
t.Fatalf("update notes: %v", err)
}
updated, _ := faves.GetByID(fave.ID)
if updated.Notes != "Oppdatert anmeldelse" {
t.Errorf("updated notes = %q", updated.Notes)
}
// Notes appear in list queries.
list, _, _ := faves.ListByUser(user.ID, 10, 0)
if len(list) != 1 || list[0].Notes != "Oppdatert anmeldelse" {
t.Error("notes should be loaded in list queries")
}
// Empty notes by default.
fave2, _ := faves.Create(user.ID, "No notes", "", "", "", "public")
if fave2.Notes != "" {
t.Errorf("default notes = %q, want empty", fave2.Notes)
}
}
func TestListByTag(t *testing.T) {
db := testDB(t)
users := NewUserStore(db)
@ -120,9 +163,9 @@ func TestListByTag(t *testing.T) {
user, _ := users.Create("testuser", "password123", "user")
// Create two public faves with overlapping tags.
f1, _ := faves.Create(user.ID, "Fave 1", "", "", "public")
f2, _ := faves.Create(user.ID, "Fave 2", "", "", "public")
f3, _ := faves.Create(user.ID, "Private Fave", "", "", "private")
f1, _ := faves.Create(user.ID, "Fave 1", "", "", "", "public")
f2, _ := faves.Create(user.ID, "Fave 2", "", "", "", "public")
f3, _ := faves.Create(user.ID, "Private Fave", "", "", "", "private")
tags.SetFaveTags(f1.ID, []string{"music", "jazz"})
tags.SetFaveTags(f2.ID, []string{"music", "rock"})
@ -154,7 +197,7 @@ func TestFavePagination(t *testing.T) {
// Create 5 faves.
for i := 0; i < 5; i++ {
faves.Create(user.ID, "Fave "+string(rune('A'+i)), "", "", "public")
faves.Create(user.ID, "Fave "+string(rune('A'+i)), "", "", "", "public")
}
// Page 1 with limit 2.

View file

@ -42,8 +42,8 @@ func TestTagSearch(t *testing.T) {
user, _ := users.Create("testuser", "password123", "user")
// Create some tags via faves to give them usage counts.
f1, _ := faves.Create(user.ID, "F1", "", "", "public")
f2, _ := faves.Create(user.ID, "F2", "", "", "public")
f1, _ := faves.Create(user.ID, "Fave 1", "", "", "", "public")
f2, _ := faves.Create(user.ID, "Fave 2", "", "", "", "public")
tags.SetFaveTags(f1.ID, []string{"music", "movies", "misc"})
tags.SetFaveTags(f2.ID, []string{"music", "manga"})
@ -94,7 +94,7 @@ func TestTagSetFaveTagsLimit(t *testing.T) {
defer func() { Argon2Memory = 65536; Argon2Time = 3 }()
user, _ := users.Create("testuser", "password123", "user")
fave, _ := faves.Create(user.ID, "Test", "", "", "public")
fave, _ := faves.Create(user.ID, "Test", "", "", "", "public")
// Try to set more than MaxTagsPerFave tags.
manyTags := make([]string, 30)
@ -124,7 +124,7 @@ func TestTagCleanupOrphans(t *testing.T) {
defer func() { Argon2Memory = 65536; Argon2Time = 3 }()
user, _ := users.Create("testuser", "password123", "user")
fave, _ := faves.Create(user.ID, "Test", "", "", "public")
fave, _ := faves.Create(user.ID, "Test", "", "", "", "public")
tags.SetFaveTags(fave.ID, []string{"keep", "orphan"})

View file

@ -2,7 +2,13 @@
{{with .Data}}{{with .Fave}}
{{if eq .Privacy "public"}}
<meta property="og:title" content="{{truncate 70 .Description}}">
<meta property="og:description" content="En favoritt av {{.DisplayName}} på {{$.SiteName}}">
{{if .Notes}}
<meta property="og:description" content="{{truncate 200 .Notes}}">
{{else if .URL}}
<meta property="og:description" content="{{.URL}}">
{{else}}
<meta property="og:description" content="En favoritt av {{.DisplayName}} på {{$.SiteName}}">
{{end}}
<meta property="og:type" content="article">
{{if $.ExternalURL}}
<meta property="og:url" content="{{$.ExternalURL}}/faves/{{.ID}}">
@ -45,6 +51,10 @@
<p><a href="{{.URL}}" target="_blank" rel="noopener noreferrer">{{.URL}}</a></p>
{{end}}
{{if .Notes}}
<div class="fave-notes">{{.Notes}}</div>
{{end}}
{{if .Tags}}
<p>
{{range .Tags}}

View file

@ -30,6 +30,13 @@
placeholder="https://...">
</label>
<label for="notes">
Notater (valgfri)
<textarea id="notes" name="notes"
rows="4"
placeholder="Utfyllende tekst, anmeldelse, tanker...">{{.Notes}}</textarea>
</label>
<label for="image">
Bilde (valgfri)
<input type="file" id="image" name="image"