diff --git a/internal/database/migrations/002_add_fave_notes.sql b/internal/database/migrations/002_add_fave_notes.sql new file mode 100644 index 0000000..b4cf1cc --- /dev/null +++ b/internal/database/migrations/002_add_fave_notes.sql @@ -0,0 +1,2 @@ +-- Legger til et valgfritt notatfelt på favoritter. +ALTER TABLE faves ADD COLUMN notes TEXT NOT NULL DEFAULT ''; diff --git a/internal/handler/api/api.go b/internal/handler/api/api.go index 2c3e685..7996a16 100644 --- a/internal/handler/api/api.go +++ b/internal/handler/api/api.go @@ -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, diff --git a/internal/handler/api/api_test.go b/internal/handler/api/api_test.go index 8158d15..4eb9270 100644 --- a/internal/handler/api/api_test.go +++ b/internal/handler/api/api_test.go @@ -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} diff --git a/internal/handler/fave.go b/internal/handler/fave.go index 220093c..9a5dd82 100644 --- a/internal/handler/fave.go +++ b/internal/handler/fave.go @@ -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 diff --git a/internal/handler/feed.go b/internal/handler/feed.go index d5d945c..d1eaba0 100644 --- a/internal/handler/feed.go +++ b/internal/handler/feed.go @@ -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 = `

` + escaped + `

` diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go index e12d232..e21672d 100644 --- a/internal/handler/handler_test.go +++ b/internal/handler/handler_test.go @@ -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() diff --git a/internal/handler/import_export.go b/internal/handler/import_export.go index 9978570..5dcd2d9 100644 --- a/internal/handler/import_export.go +++ b/internal/handler/import_export.go @@ -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] } diff --git a/internal/handler/web_test.go b/internal/handler/web_test.go index 5d5268a..6cda977 100644 --- a/internal/handler/web_test.go +++ b/internal/handler/web_test.go @@ -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 " — with no display name set, // it should fall back to the username. diff --git a/internal/model/fave.go b/internal/model/fave.go index 6b81290..beda156 100644 --- a/internal/model/fave.go +++ b/internal/model/fave.go @@ -10,6 +10,7 @@ type Fave struct { Description string URL string ImagePath string + Notes string Privacy string Tags []Tag CreatedAt time.Time diff --git a/internal/store/fave.go b/internal/store/fave.go index 70d9613..5888490 100644 --- a/internal/store/fave.go +++ b/internal/store/fave.go @@ -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 { diff --git a/internal/store/fave_test.go b/internal/store/fave_test.go index 7779b3d..f96ce8f 100644 --- a/internal/store/fave_test.go +++ b/internal/store/fave_test.go @@ -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. diff --git a/internal/store/tag_test.go b/internal/store/tag_test.go index 1e967fd..3d0d4a8 100644 --- a/internal/store/tag_test.go +++ b/internal/store/tag_test.go @@ -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"}) diff --git a/web/templates/pages/fave_detail.html b/web/templates/pages/fave_detail.html index ea671b5..74aa810 100644 --- a/web/templates/pages/fave_detail.html +++ b/web/templates/pages/fave_detail.html @@ -2,7 +2,13 @@ {{with .Data}}{{with .Fave}} {{if eq .Privacy "public"}} - + {{if .Notes}} + + {{else if .URL}} + + {{else}} + + {{end}} {{if $.ExternalURL}} @@ -45,6 +51,10 @@

{{.URL}}

{{end}} + {{if .Notes}} +
{{.Notes}}
+ {{end}} + {{if .Tags}}

{{range .Tags}} diff --git a/web/templates/pages/fave_form.html b/web/templates/pages/fave_form.html index 5dcc665..4b1cd20 100644 --- a/web/templates/pages/fave_form.html +++ b/web/templates/pages/fave_form.html @@ -30,6 +30,13 @@ placeholder="https://..."> + +