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

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

View file

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

View file

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