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 { 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,

View file

@ -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}

View file

@ -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

View file

@ -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>`

View file

@ -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()

View file

@ -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]
} }

View file

@ -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.

View file

@ -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

View file

@ -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 {

View file

@ -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.

View file

@ -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"})

View file

@ -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}}">
<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"> <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}}

View file

@ -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"