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:
parent
a8f3aa6f7e
commit
485d01ce45
14 changed files with 151 additions and 71 deletions
2
internal/database/migrations/002_add_fave_notes.sql
Normal file
2
internal/database/migrations/002_add_fave_notes.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- Legger til et valgfritt notatfelt på favoritter.
|
||||||
|
ALTER TABLE faves ADD COLUMN notes TEXT NOT NULL DEFAULT '';
|
||||||
|
|
@ -135,6 +135,7 @@ func (h *Handler) handleCreateFave(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
Privacy string `json:"privacy"`
|
Privacy string `json:"privacy"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
}
|
}
|
||||||
|
|
@ -151,7 +152,7 @@ func (h *Handler) handleCreateFave(w http.ResponseWriter, r *http.Request) {
|
||||||
req.Privacy = user.DefaultFavePrivacy
|
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 {
|
if err != nil {
|
||||||
slog.Error("api: create fave error", "error", err)
|
slog.Error("api: create fave error", "error", err)
|
||||||
jsonError(w, "Internal error", http.StatusInternalServerError)
|
jsonError(w, "Internal error", http.StatusInternalServerError)
|
||||||
|
|
@ -222,6 +223,7 @@ func (h *Handler) handleUpdateFave(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
Privacy string `json:"privacy"`
|
Privacy string `json:"privacy"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
}
|
}
|
||||||
|
|
@ -237,7 +239,7 @@ func (h *Handler) handleUpdateFave(w http.ResponseWriter, r *http.Request) {
|
||||||
req.Privacy = fave.Privacy
|
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)
|
slog.Error("api: update fave error", "error", err)
|
||||||
jsonError(w, "Internal error", http.StatusInternalServerError)
|
jsonError(w, "Internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
@ -378,6 +380,7 @@ func (h *Handler) handleImport(w http.ResponseWriter, r *http.Request) {
|
||||||
var faves []struct {
|
var faves []struct {
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
Privacy string `json:"privacy"`
|
Privacy string `json:"privacy"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
}
|
}
|
||||||
|
|
@ -395,7 +398,7 @@ func (h *Handler) handleImport(w http.ResponseWriter, r *http.Request) {
|
||||||
if privacy != "public" && privacy != "private" {
|
if privacy != "public" && privacy != "private" {
|
||||||
privacy = user.DefaultFavePrivacy
|
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 {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -446,6 +449,7 @@ func faveJSON(f *model.Fave) map[string]any {
|
||||||
"id": f.ID,
|
"id": f.ID,
|
||||||
"description": f.Description,
|
"description": f.Description,
|
||||||
"url": f.URL,
|
"url": f.URL,
|
||||||
|
"notes": f.Notes,
|
||||||
"image_path": f.ImagePath,
|
"image_path": f.ImagePath,
|
||||||
"privacy": f.Privacy,
|
"privacy": f.Privacy,
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
|
|
|
||||||
|
|
@ -222,7 +222,7 @@ func TestAPIGetFave(t *testing.T) {
|
||||||
|
|
||||||
// Create a public fave directly.
|
// Create a public fave directly.
|
||||||
user, _ := users.GetByUsername("testuser")
|
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"})
|
h.deps.Tags.SetFaveTags(fave.ID, []string{"test"})
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/api/v1/faves/"+faveIDStr(fave.ID), nil)
|
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.
|
// User A creates a private fave.
|
||||||
userA, _ := users.Create("usera", "pass123", "user")
|
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.
|
// User B tries to access it.
|
||||||
cookieB := apiLogin(t, users, sessions, "userb", "pass123", "user")
|
cookieB := apiLogin(t, users, sessions, "userb", "pass123", "user")
|
||||||
|
|
@ -276,7 +276,7 @@ func TestAPIPrivateFaveVisibleToOwner(t *testing.T) {
|
||||||
h, mux, users, sessions := testAPIServer(t)
|
h, mux, users, sessions := testAPIServer(t)
|
||||||
|
|
||||||
userA, _ := users.Create("usera", "pass123", "user")
|
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)
|
tokenA, _ := sessions.Create(userA.ID)
|
||||||
cookieA := &http.Cookie{Name: "session", Value: tokenA}
|
cookieA := &http.Cookie{Name: "session", Value: tokenA}
|
||||||
|
|
@ -295,7 +295,7 @@ func TestAPIUpdateFave(t *testing.T) {
|
||||||
h, mux, users, sessions := testAPIServer(t)
|
h, mux, users, sessions := testAPIServer(t)
|
||||||
|
|
||||||
user, _ := users.Create("testuser", "pass123", "user")
|
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)
|
token, _ := sessions.Create(user.ID)
|
||||||
cookie := &http.Cookie{Name: "session", Value: token}
|
cookie := &http.Cookie{Name: "session", Value: token}
|
||||||
|
|
||||||
|
|
@ -322,7 +322,7 @@ func TestAPIUpdateFaveNotOwner(t *testing.T) {
|
||||||
h, mux, users, sessions := testAPIServer(t)
|
h, mux, users, sessions := testAPIServer(t)
|
||||||
|
|
||||||
userA, _ := users.Create("usera", "pass123", "user")
|
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")
|
cookieB := apiLogin(t, users, sessions, "userb", "pass123", "user")
|
||||||
|
|
||||||
|
|
@ -341,7 +341,7 @@ func TestAPIDeleteFave(t *testing.T) {
|
||||||
h, mux, users, sessions := testAPIServer(t)
|
h, mux, users, sessions := testAPIServer(t)
|
||||||
|
|
||||||
user, _ := users.Create("testuser", "pass123", "user")
|
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)
|
token, _ := sessions.Create(user.ID)
|
||||||
cookie := &http.Cookie{Name: "session", Value: token}
|
cookie := &http.Cookie{Name: "session", Value: token}
|
||||||
|
|
||||||
|
|
@ -368,7 +368,7 @@ func TestAPIDeleteFaveNotOwner(t *testing.T) {
|
||||||
h, mux, users, sessions := testAPIServer(t)
|
h, mux, users, sessions := testAPIServer(t)
|
||||||
|
|
||||||
userA, _ := users.Create("usera", "pass123", "user")
|
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")
|
cookieB := apiLogin(t, users, sessions, "userb", "pass123", "user")
|
||||||
|
|
||||||
|
|
@ -386,9 +386,9 @@ func TestAPIListFaves(t *testing.T) {
|
||||||
h, mux, users, sessions := testAPIServer(t)
|
h, mux, users, sessions := testAPIServer(t)
|
||||||
|
|
||||||
user, _ := users.Create("testuser", "pass123", "user")
|
user, _ := users.Create("testuser", "pass123", "user")
|
||||||
h.deps.Faves.Create(user.ID, "Fave 1", "", "", "public")
|
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 2", "", "", "", "public")
|
||||||
h.deps.Faves.Create(user.ID, "Fave 3", "", "", "private")
|
h.deps.Faves.Create(user.ID, "Fave 3", "", "", "", "private")
|
||||||
token, _ := sessions.Create(user.ID)
|
token, _ := sessions.Create(user.ID)
|
||||||
cookie := &http.Cookie{Name: "session", Value: token}
|
cookie := &http.Cookie{Name: "session", Value: token}
|
||||||
|
|
||||||
|
|
@ -413,7 +413,7 @@ func TestAPIListFavesPagination(t *testing.T) {
|
||||||
|
|
||||||
user, _ := users.Create("testuser", "pass123", "user")
|
user, _ := users.Create("testuser", "pass123", "user")
|
||||||
for i := 0; i < 5; i++ {
|
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)
|
token, _ := sessions.Create(user.ID)
|
||||||
cookie := &http.Cookie{Name: "session", Value: token}
|
cookie := &http.Cookie{Name: "session", Value: token}
|
||||||
|
|
@ -443,7 +443,7 @@ func TestAPISearchTags(t *testing.T) {
|
||||||
h, mux, users, _ := testAPIServer(t)
|
h, mux, users, _ := testAPIServer(t)
|
||||||
|
|
||||||
user, _ := users.Create("testuser", "pass123", "user")
|
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"})
|
h.deps.Tags.SetFaveTags(fave.ID, []string{"golang", "goroutines", "python"})
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/api/v1/tags?q=go", nil)
|
req := httptest.NewRequest("GET", "/api/v1/tags?q=go", nil)
|
||||||
|
|
@ -531,8 +531,8 @@ func TestAPIGetDisabledUser(t *testing.T) {
|
||||||
func TestAPIGetUserFaves(t *testing.T) {
|
func TestAPIGetUserFaves(t *testing.T) {
|
||||||
h, mux, users, _ := testAPIServer(t)
|
h, mux, users, _ := testAPIServer(t)
|
||||||
user, _ := users.Create("testuser", "pass123", "user")
|
user, _ := users.Create("testuser", "pass123", "user")
|
||||||
h.deps.Faves.Create(user.ID, "Public fave", "", "", "public")
|
h.deps.Faves.Create(user.ID, "Public fave", "", "", "", "public")
|
||||||
h.deps.Faves.Create(user.ID, "Private fave", "", "", "private")
|
h.deps.Faves.Create(user.ID, "Private fave", "", "", "", "private")
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/api/v1/users/testuser/faves", nil)
|
req := httptest.NewRequest("GET", "/api/v1/users/testuser/faves", nil)
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
|
|
@ -555,8 +555,8 @@ func TestAPIExport(t *testing.T) {
|
||||||
h, mux, users, sessions := testAPIServer(t)
|
h, mux, users, sessions := testAPIServer(t)
|
||||||
|
|
||||||
user, _ := users.Create("testuser", "pass123", "user")
|
user, _ := users.Create("testuser", "pass123", "user")
|
||||||
h.deps.Faves.Create(user.ID, "Fave 1", "", "", "public")
|
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 2", "", "", "", "private")
|
||||||
token, _ := sessions.Create(user.ID)
|
token, _ := sessions.Create(user.ID)
|
||||||
cookie := &http.Cookie{Name: "session", Value: token}
|
cookie := &http.Cookie{Name: "session", Value: token}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,12 +72,13 @@ func (h *Handler) handleFaveCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
description := strings.TrimSpace(r.FormValue("description"))
|
description := strings.TrimSpace(r.FormValue("description"))
|
||||||
url := strings.TrimSpace(r.FormValue("url"))
|
url := strings.TrimSpace(r.FormValue("url"))
|
||||||
|
notes := strings.TrimSpace(r.FormValue("notes"))
|
||||||
privacy := r.FormValue("privacy")
|
privacy := r.FormValue("privacy")
|
||||||
tagStr := r.FormValue("tags")
|
tagStr := r.FormValue("tags")
|
||||||
|
|
||||||
if description == "" {
|
if description == "" {
|
||||||
h.flash(w, r, "fave_form", "Beskrivelse er påkrevd.", "error", map[string]any{
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -95,7 +96,7 @@ func (h *Handler) handleFaveCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("image process error", "error", err)
|
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{
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -103,7 +104,7 @@ func (h *Handler) handleFaveCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the fave.
|
// 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 {
|
if err != nil {
|
||||||
slog.Error("create fave error", "error", err)
|
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})
|
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,
|
"Fave": fave,
|
||||||
"Description": fave.Description,
|
"Description": fave.Description,
|
||||||
"URL": fave.URL,
|
"URL": fave.URL,
|
||||||
|
"Notes": fave.Notes,
|
||||||
"Privacy": fave.Privacy,
|
"Privacy": fave.Privacy,
|
||||||
"Tags": strings.Join(tagNames, ", "),
|
"Tags": strings.Join(tagNames, ", "),
|
||||||
},
|
},
|
||||||
|
|
@ -244,6 +246,7 @@ func (h *Handler) handleFaveUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
description := strings.TrimSpace(r.FormValue("description"))
|
description := strings.TrimSpace(r.FormValue("description"))
|
||||||
url := strings.TrimSpace(r.FormValue("url"))
|
url := strings.TrimSpace(r.FormValue("url"))
|
||||||
|
notes := strings.TrimSpace(r.FormValue("notes"))
|
||||||
privacy := r.FormValue("privacy")
|
privacy := r.FormValue("privacy")
|
||||||
tagStr := r.FormValue("tags")
|
tagStr := r.FormValue("tags")
|
||||||
|
|
||||||
|
|
@ -283,7 +286,7 @@ func (h *Handler) handleFaveUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
imagePath = ""
|
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)
|
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})
|
h.flash(w, r, "fave_form", "Noe gikk galt. Prøv igjen.", "error", map[string]any{"IsNew": false, "Fave": fave})
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,10 @@ func favesToFeedItems(faves []*model.Fave, baseURL string) []*feeds.Item {
|
||||||
Updated: f.UpdatedAt,
|
Updated: f.UpdatedAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if f.Notes != "" {
|
||||||
|
item.Description = f.Notes
|
||||||
|
}
|
||||||
|
|
||||||
if f.URL != "" {
|
if f.URL != "" {
|
||||||
escaped := html.EscapeString(f.URL)
|
escaped := html.EscapeString(f.URL)
|
||||||
item.Content = `<p><a href="` + escaped + `">` + escaped + `</a></p>`
|
item.Content = `<p><a href="` + escaped + `">` + escaped + `</a></p>`
|
||||||
|
|
|
||||||
|
|
@ -220,7 +220,7 @@ func TestPrivateFaveHiddenFromOthers(t *testing.T) {
|
||||||
|
|
||||||
// User A creates a private fave.
|
// User A creates a private fave.
|
||||||
userA, _ := h.deps.Users.Create("usera", "pass123", "user")
|
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.
|
// User B tries to view it.
|
||||||
cookieB := loginUser(t, h, "userb", "pass123", "user")
|
cookieB := loginUser(t, h, "userb", "pass123", "user")
|
||||||
|
|
@ -239,7 +239,7 @@ func TestPrivateFaveVisibleToOwner(t *testing.T) {
|
||||||
h, mux := testServer(t)
|
h, mux := testServer(t)
|
||||||
|
|
||||||
userA, _ := h.deps.Users.Create("usera", "pass123", "user")
|
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)
|
tokenA, _ := h.deps.Sessions.Create(userA.ID)
|
||||||
cookieA := &http.Cookie{Name: "session", Value: tokenA}
|
cookieA := &http.Cookie{Name: "session", Value: tokenA}
|
||||||
|
|
@ -290,7 +290,7 @@ func TestTagSearchEndpoint(t *testing.T) {
|
||||||
|
|
||||||
// Create some tags via faves.
|
// Create some tags via faves.
|
||||||
user, _ := h.deps.Users.Create("testuser", "pass123", "user")
|
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"})
|
h.deps.Tags.SetFaveTags(fave.ID, []string{"music", "movies", "manga"})
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/tags/search?q=mu", nil)
|
req := httptest.NewRequest("GET", "/tags/search?q=mu", nil)
|
||||||
|
|
@ -310,7 +310,7 @@ func TestFeedGlobal(t *testing.T) {
|
||||||
h, mux := testServer(t)
|
h, mux := testServer(t)
|
||||||
|
|
||||||
user, _ := h.deps.Users.Create("testuser", "pass123", "user")
|
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)
|
req := httptest.NewRequest("GET", "/feed.xml", nil)
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ const maxExportFaves = 100000
|
||||||
type ExportFave struct {
|
type ExportFave struct {
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
|
Notes string `json:"notes,omitempty"`
|
||||||
Privacy string `json:"privacy"`
|
Privacy string `json:"privacy"`
|
||||||
Tags []string `json:"tags,omitempty"`
|
Tags []string `json:"tags,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
|
|
@ -57,6 +58,7 @@ func (h *Handler) handleExportJSON(w http.ResponseWriter, r *http.Request) {
|
||||||
export[i] = ExportFave{
|
export[i] = ExportFave{
|
||||||
Description: f.Description,
|
Description: f.Description,
|
||||||
URL: f.URL,
|
URL: f.URL,
|
||||||
|
Notes: f.Notes,
|
||||||
Privacy: f.Privacy,
|
Privacy: f.Privacy,
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
CreatedAt: f.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
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")
|
w.Header().Set("Content-Disposition", "attachment; filename=favoritter.csv")
|
||||||
|
|
||||||
cw := csv.NewWriter(w)
|
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 {
|
for _, f := range faves {
|
||||||
tags := make([]string, len(f.Tags))
|
tags := make([]string, len(f.Tags))
|
||||||
|
|
@ -99,6 +101,7 @@ func (h *Handler) handleExportCSV(w http.ResponseWriter, r *http.Request) {
|
||||||
cw.Write([]string{
|
cw.Write([]string{
|
||||||
f.Description,
|
f.Description,
|
||||||
f.URL,
|
f.URL,
|
||||||
|
f.Notes,
|
||||||
f.Privacy,
|
f.Privacy,
|
||||||
strings.Join(tags, ","),
|
strings.Join(tags, ","),
|
||||||
f.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
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
|
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 {
|
if err != nil {
|
||||||
slog.Error("import: create fave error", "error", err)
|
slog.Error("import: create fave error", "error", err)
|
||||||
continue
|
continue
|
||||||
|
|
@ -213,6 +216,9 @@ func parseImportCSV(r io.Reader) ([]ExportFave, error) {
|
||||||
if idx, ok := colMap["url"]; ok && idx < len(row) {
|
if idx, ok := colMap["url"]; ok && idx < len(row) {
|
||||||
f.URL = row[idx]
|
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) {
|
if idx, ok := colMap["privacy"]; ok && idx < len(row) {
|
||||||
f.Privacy = row[idx]
|
f.Privacy = row[idx]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -364,7 +364,7 @@ func TestEditFaveNotOwner(t *testing.T) {
|
||||||
h, mux := testServer(t)
|
h, mux := testServer(t)
|
||||||
|
|
||||||
userA, _ := h.deps.Users.Create("usera", "pass123", "user")
|
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")
|
cookieB := loginUser(t, h, "userb", "pass123", "user")
|
||||||
|
|
||||||
|
|
@ -382,7 +382,7 @@ func TestDeleteFaveHTMX(t *testing.T) {
|
||||||
h, mux := testServer(t)
|
h, mux := testServer(t)
|
||||||
|
|
||||||
user, _ := h.deps.Users.Create("testuser", "pass123", "user")
|
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)
|
token, _ := h.deps.Sessions.Create(user.ID)
|
||||||
cookie := &http.Cookie{Name: "session", Value: token}
|
cookie := &http.Cookie{Name: "session", Value: token}
|
||||||
|
|
||||||
|
|
@ -409,7 +409,7 @@ func TestDeleteFaveNotOwner(t *testing.T) {
|
||||||
h, mux := testServer(t)
|
h, mux := testServer(t)
|
||||||
|
|
||||||
userA, _ := h.deps.Users.Create("usera", "pass123", "user")
|
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")
|
cookieB := loginUser(t, h, "userb", "pass123", "user")
|
||||||
|
|
||||||
|
|
@ -623,7 +623,7 @@ func TestAdminTags(t *testing.T) {
|
||||||
|
|
||||||
// Create a tag via a fave.
|
// Create a tag via a fave.
|
||||||
admin, _ := h.deps.Users.GetByUsername("admin")
|
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"})
|
h.deps.Tags.SetFaveTags(fave.ID, []string{"testmerke"})
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/admin/tags", nil)
|
req := httptest.NewRequest("GET", "/admin/tags", nil)
|
||||||
|
|
@ -646,7 +646,7 @@ func TestUserFeed(t *testing.T) {
|
||||||
|
|
||||||
user, _ := h.deps.Users.Create("feeduser", "pass123", "user")
|
user, _ := h.deps.Users.Create("feeduser", "pass123", "user")
|
||||||
h.deps.Users.UpdateProfile(user.ID, "Feed User", "", "public", "public")
|
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)
|
req := httptest.NewRequest("GET", "/u/feeduser/feed.xml", nil)
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
|
|
@ -668,8 +668,8 @@ func TestFeedExcludesPrivate(t *testing.T) {
|
||||||
h, mux := testServer(t)
|
h, mux := testServer(t)
|
||||||
|
|
||||||
user, _ := h.deps.Users.Create("testuser", "pass123", "user")
|
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")
|
||||||
h.deps.Faves.Create(user.ID, "Secret fave", "", "", "private")
|
h.deps.Faves.Create(user.ID, "Secret fave", "", "", "", "private")
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/feed.xml", nil)
|
req := httptest.NewRequest("GET", "/feed.xml", nil)
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
|
|
@ -688,7 +688,7 @@ func TestTagFeed(t *testing.T) {
|
||||||
h, mux := testServer(t)
|
h, mux := testServer(t)
|
||||||
|
|
||||||
user, _ := h.deps.Users.Create("testuser", "pass123", "user")
|
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"})
|
h.deps.Tags.SetFaveTags(fave.ID, []string{"golang"})
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/tags/golang/feed.xml", nil)
|
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")
|
cookie := loginUser(t, h, "testuser", "pass123", "user")
|
||||||
|
|
||||||
user, _ := h.deps.Users.GetByUsername("testuser")
|
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"})
|
h.deps.Tags.SetFaveTags(fave.ID, []string{"test"})
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/export/json", nil)
|
req := httptest.NewRequest("GET", "/export/json", nil)
|
||||||
|
|
@ -759,7 +759,7 @@ func TestExportCSV(t *testing.T) {
|
||||||
cookie := loginUser(t, h, "testuser", "pass123", "user")
|
cookie := loginUser(t, h, "testuser", "pass123", "user")
|
||||||
|
|
||||||
user, _ := h.deps.Users.GetByUsername("testuser")
|
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 := httptest.NewRequest("GET", "/export/csv", nil)
|
||||||
req.AddCookie(cookie)
|
req.AddCookie(cookie)
|
||||||
|
|
@ -871,7 +871,7 @@ func TestProfileOwnerSeesPrivateFaves(t *testing.T) {
|
||||||
h, mux := testServer(t)
|
h, mux := testServer(t)
|
||||||
|
|
||||||
user, _ := h.deps.Users.Create("owner", "pass123", "user")
|
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)
|
token, _ := h.deps.Sessions.Create(user.ID)
|
||||||
cookie := &http.Cookie{Name: "session", Value: token}
|
cookie := &http.Cookie{Name: "session", Value: token}
|
||||||
|
|
||||||
|
|
@ -893,8 +893,8 @@ func TestProfileVisitorCannotSeePrivateFaves(t *testing.T) {
|
||||||
|
|
||||||
user, _ := h.deps.Users.Create("owner", "pass123", "user")
|
user, _ := h.deps.Users.Create("owner", "pass123", "user")
|
||||||
h.deps.Users.UpdateProfile(user.ID, "Owner", "", "public", "public")
|
h.deps.Users.UpdateProfile(user.ID, "Owner", "", "public", "public")
|
||||||
h.deps.Faves.Create(user.ID, "Only 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, "Hidden secret", "", "", "", "private")
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/u/owner", nil)
|
req := httptest.NewRequest("GET", "/u/owner", nil)
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
|
|
@ -915,7 +915,7 @@ func TestTagBrowse(t *testing.T) {
|
||||||
h, mux := testServer(t)
|
h, mux := testServer(t)
|
||||||
|
|
||||||
user, _ := h.deps.Users.Create("testuser", "pass123", "user")
|
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"})
|
h.deps.Tags.SetFaveTags(fave.ID, []string{"golang"})
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/tags/golang", nil)
|
req := httptest.NewRequest("GET", "/tags/golang", nil)
|
||||||
|
|
@ -949,7 +949,7 @@ func TestHomePageAuthenticated(t *testing.T) {
|
||||||
cookie := loginUser(t, h, "testuser", "pass123", "user")
|
cookie := loginUser(t, h, "testuser", "pass123", "user")
|
||||||
|
|
||||||
user, _ := h.deps.Users.GetByUsername("testuser")
|
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 := httptest.NewRequest("GET", "/", nil)
|
||||||
req.AddCookie(cookie)
|
req.AddCookie(cookie)
|
||||||
|
|
@ -1074,7 +1074,7 @@ func TestTagSuggestionsNoInlineHandlers(t *testing.T) {
|
||||||
h, mux := testServer(t)
|
h, mux := testServer(t)
|
||||||
|
|
||||||
user, _ := h.deps.Users.Create("testuser", "pass123", "user")
|
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"})
|
h.deps.Tags.SetFaveTags(fave.ID, []string{"golang", "goroutines"})
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/tags/search?q=go", nil)
|
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.
|
// Create a user WITHOUT a display name and a public fave.
|
||||||
user, _ := h.deps.Users.GetByUsername("testuser")
|
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,
|
// The home page shows "av <display_name>" — with no display name set,
|
||||||
// it should fall back to the username.
|
// it should fall back to the username.
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ type Fave struct {
|
||||||
Description string
|
Description string
|
||||||
URL string
|
URL string
|
||||||
ImagePath string
|
ImagePath string
|
||||||
|
Notes string
|
||||||
Privacy string
|
Privacy string
|
||||||
Tags []Tag
|
Tags []Tag
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,11 @@ func NewFaveStore(db *sql.DB) *FaveStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create inserts a new fave and returns it with its ID populated.
|
// 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(
|
result, err := s.db.Exec(
|
||||||
`INSERT INTO faves (user_id, description, url, image_path, privacy)
|
`INSERT INTO faves (user_id, description, url, image_path, notes, privacy)
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
userID, description, url, imagePath, privacy,
|
userID, description, url, imagePath, notes, privacy,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("insert fave: %w", err)
|
return nil, fmt.Errorf("insert fave: %w", err)
|
||||||
|
|
@ -42,13 +42,13 @@ func (s *FaveStore) GetByID(id int64) (*model.Fave, error) {
|
||||||
f := &model.Fave{}
|
f := &model.Fave{}
|
||||||
var createdAt, updatedAt string
|
var createdAt, updatedAt string
|
||||||
err := s.db.QueryRow(
|
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)
|
f.created_at, f.updated_at, u.username, COALESCE(NULLIF(u.display_name, ''), u.username)
|
||||||
FROM faves f
|
FROM faves f
|
||||||
JOIN users u ON u.id = f.user_id
|
JOIN users u ON u.id = f.user_id
|
||||||
WHERE f.id = ?`, id,
|
WHERE f.id = ?`, id,
|
||||||
).Scan(
|
).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,
|
&createdAt, &updatedAt, &f.Username, &f.DisplayName,
|
||||||
)
|
)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
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.
|
// 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(
|
_, 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')
|
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
description, url, imagePath, privacy, id,
|
description, url, imagePath, notes, privacy, id,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -96,7 +96,7 @@ func (s *FaveStore) ListByUser(userID int64, limit, offset int) ([]*model.Fave,
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := s.db.Query(
|
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)
|
f.created_at, f.updated_at, u.username, COALESCE(NULLIF(u.display_name, ''), u.username)
|
||||||
FROM faves f
|
FROM faves f
|
||||||
JOIN users u ON u.id = f.user_id
|
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(
|
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)
|
f.created_at, f.updated_at, u.username, COALESCE(NULLIF(u.display_name, ''), u.username)
|
||||||
FROM faves f
|
FROM faves f
|
||||||
JOIN users u ON u.id = f.user_id
|
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(
|
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)
|
f.created_at, f.updated_at, u.username, COALESCE(NULLIF(u.display_name, ''), u.username)
|
||||||
FROM faves f
|
FROM faves f
|
||||||
JOIN users u ON u.id = f.user_id
|
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(
|
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)
|
f.created_at, f.updated_at, u.username, COALESCE(NULLIF(u.display_name, ''), u.username)
|
||||||
FROM faves f
|
FROM faves f
|
||||||
JOIN users u ON u.id = f.user_id
|
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{}
|
f := &model.Fave{}
|
||||||
var createdAt, updatedAt string
|
var createdAt, updatedAt string
|
||||||
err := rows.Scan(
|
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,
|
&createdAt, &updatedAt, &f.Username, &f.DisplayName,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ func TestFaveCRUD(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a fave.
|
// 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 {
|
if err != nil {
|
||||||
t.Fatalf("create fave: %v", err)
|
t.Fatalf("create fave: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -44,7 +44,7 @@ func TestFaveCRUD(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update.
|
// 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 {
|
if err != nil {
|
||||||
t.Fatalf("update fave: %v", err)
|
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) {
|
func TestListByTag(t *testing.T) {
|
||||||
db := testDB(t)
|
db := testDB(t)
|
||||||
users := NewUserStore(db)
|
users := NewUserStore(db)
|
||||||
|
|
@ -120,9 +163,9 @@ func TestListByTag(t *testing.T) {
|
||||||
user, _ := users.Create("testuser", "password123", "user")
|
user, _ := users.Create("testuser", "password123", "user")
|
||||||
|
|
||||||
// Create two public faves with overlapping tags.
|
// Create two public faves with overlapping tags.
|
||||||
f1, _ := faves.Create(user.ID, "Fave 1", "", "", "public")
|
f1, _ := faves.Create(user.ID, "Fave 1", "", "", "", "public")
|
||||||
f2, _ := faves.Create(user.ID, "Fave 2", "", "", "public")
|
f2, _ := faves.Create(user.ID, "Fave 2", "", "", "", "public")
|
||||||
f3, _ := faves.Create(user.ID, "Private Fave", "", "", "private")
|
f3, _ := faves.Create(user.ID, "Private Fave", "", "", "", "private")
|
||||||
|
|
||||||
tags.SetFaveTags(f1.ID, []string{"music", "jazz"})
|
tags.SetFaveTags(f1.ID, []string{"music", "jazz"})
|
||||||
tags.SetFaveTags(f2.ID, []string{"music", "rock"})
|
tags.SetFaveTags(f2.ID, []string{"music", "rock"})
|
||||||
|
|
@ -154,7 +197,7 @@ func TestFavePagination(t *testing.T) {
|
||||||
|
|
||||||
// Create 5 faves.
|
// Create 5 faves.
|
||||||
for i := 0; i < 5; i++ {
|
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.
|
// Page 1 with limit 2.
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,8 @@ func TestTagSearch(t *testing.T) {
|
||||||
user, _ := users.Create("testuser", "password123", "user")
|
user, _ := users.Create("testuser", "password123", "user")
|
||||||
|
|
||||||
// Create some tags via faves to give them usage counts.
|
// Create some tags via faves to give them usage counts.
|
||||||
f1, _ := faves.Create(user.ID, "F1", "", "", "public")
|
f1, _ := faves.Create(user.ID, "Fave 1", "", "", "", "public")
|
||||||
f2, _ := faves.Create(user.ID, "F2", "", "", "public")
|
f2, _ := faves.Create(user.ID, "Fave 2", "", "", "", "public")
|
||||||
|
|
||||||
tags.SetFaveTags(f1.ID, []string{"music", "movies", "misc"})
|
tags.SetFaveTags(f1.ID, []string{"music", "movies", "misc"})
|
||||||
tags.SetFaveTags(f2.ID, []string{"music", "manga"})
|
tags.SetFaveTags(f2.ID, []string{"music", "manga"})
|
||||||
|
|
@ -94,7 +94,7 @@ func TestTagSetFaveTagsLimit(t *testing.T) {
|
||||||
defer func() { Argon2Memory = 65536; Argon2Time = 3 }()
|
defer func() { Argon2Memory = 65536; Argon2Time = 3 }()
|
||||||
|
|
||||||
user, _ := users.Create("testuser", "password123", "user")
|
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.
|
// Try to set more than MaxTagsPerFave tags.
|
||||||
manyTags := make([]string, 30)
|
manyTags := make([]string, 30)
|
||||||
|
|
@ -124,7 +124,7 @@ func TestTagCleanupOrphans(t *testing.T) {
|
||||||
defer func() { Argon2Memory = 65536; Argon2Time = 3 }()
|
defer func() { Argon2Memory = 65536; Argon2Time = 3 }()
|
||||||
|
|
||||||
user, _ := users.Create("testuser", "password123", "user")
|
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"})
|
tags.SetFaveTags(fave.ID, []string{"keep", "orphan"})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,13 @@
|
||||||
{{with .Data}}{{with .Fave}}
|
{{with .Data}}{{with .Fave}}
|
||||||
{{if eq .Privacy "public"}}
|
{{if eq .Privacy "public"}}
|
||||||
<meta property="og:title" content="{{truncate 70 .Description}}">
|
<meta property="og:title" content="{{truncate 70 .Description}}">
|
||||||
|
{{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}}">
|
<meta property="og:description" content="En favoritt av {{.DisplayName}} på {{$.SiteName}}">
|
||||||
|
{{end}}
|
||||||
<meta property="og:type" content="article">
|
<meta property="og:type" content="article">
|
||||||
{{if $.ExternalURL}}
|
{{if $.ExternalURL}}
|
||||||
<meta property="og:url" content="{{$.ExternalURL}}/faves/{{.ID}}">
|
<meta property="og:url" content="{{$.ExternalURL}}/faves/{{.ID}}">
|
||||||
|
|
@ -45,6 +51,10 @@
|
||||||
<p><a href="{{.URL}}" target="_blank" rel="noopener noreferrer">{{.URL}}</a></p>
|
<p><a href="{{.URL}}" target="_blank" rel="noopener noreferrer">{{.URL}}</a></p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Notes}}
|
||||||
|
<div class="fave-notes">{{.Notes}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{if .Tags}}
|
{{if .Tags}}
|
||||||
<p>
|
<p>
|
||||||
{{range .Tags}}
|
{{range .Tags}}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,13 @@
|
||||||
placeholder="https://...">
|
placeholder="https://...">
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label for="notes">
|
||||||
|
Notater (valgfri)
|
||||||
|
<textarea id="notes" name="notes"
|
||||||
|
rows="4"
|
||||||
|
placeholder="Utfyllende tekst, anmeldelse, tanker...">{{.Notes}}</textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label for="image">
|
<label for="image">
|
||||||
Bilde (valgfri)
|
Bilde (valgfri)
|
||||||
<input type="file" id="image" name="image"
|
<input type="file" id="image" name="image"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue