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
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue