- Store Twitter video URLs in embed_video_url column instead of downloading - Play videos directly from Twitter's CDN in <video> element - Fix Twitter API parsing (content_type/url fields) - Strip t.co URLs from tweet text descriptions - Use sql.Null[T] generic type for nullable DB columns - Add Nullable[T] and Ptr[T] helper functions - Add play indicator overlay for video items in grid - Add migration for embed_video_url column
244 lines
6.8 KiB
Go
244 lines
6.8 KiB
Go
package item
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
// Nullable creates a sql.Null[T] from a pointer.
|
|
func Nullable[T any](v *T) sql.Null[T] {
|
|
if v == nil {
|
|
return sql.Null[T]{}
|
|
}
|
|
return sql.Null[T]{V: *v, Valid: true}
|
|
}
|
|
|
|
// Ptr returns a pointer from sql.Null[T], or nil if not valid.
|
|
func Ptr[T any](n sql.Null[T]) *T {
|
|
if !n.Valid {
|
|
return nil
|
|
}
|
|
return &n.V
|
|
}
|
|
|
|
type Row struct {
|
|
ID int64
|
|
PubID string
|
|
Title sql.Null[string]
|
|
Description sql.Null[string]
|
|
LinkURL sql.Null[string]
|
|
ItemType string // 'image', 'video', 'quote', 'embed'
|
|
EmbedProvider sql.Null[string]
|
|
EmbedVideoID sql.Null[string]
|
|
EmbedHTML sql.Null[string]
|
|
EmbedVideoURL sql.Null[string]
|
|
CreatedAt time.Time
|
|
DeletedAt sql.Null[time.Time]
|
|
}
|
|
|
|
type CreateParams struct {
|
|
Title sql.Null[string]
|
|
Description sql.Null[string]
|
|
LinkURL sql.Null[string]
|
|
ItemType string
|
|
EmbedProvider sql.Null[string]
|
|
EmbedVideoID sql.Null[string]
|
|
EmbedHTML sql.Null[string]
|
|
EmbedVideoURL sql.Null[string]
|
|
}
|
|
|
|
// QCreate creates a new item.
|
|
func QCreate(ctx context.Context, db *sql.DB, p CreateParams) (Row, error) {
|
|
query := `
|
|
INSERT INTO item (title, description, link_url, item_type, embed_provider, embed_video_id, embed_html, embed_video_url)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
RETURNING id, pub_id, title, description, link_url, item_type, embed_provider, embed_video_id, embed_html, embed_video_url, created_at, deleted_at
|
|
`
|
|
|
|
var row Row
|
|
var pubID pgtype.UUID
|
|
err := db.QueryRowContext(ctx, query,
|
|
p.Title, p.Description, p.LinkURL, p.ItemType, p.EmbedProvider, p.EmbedVideoID, p.EmbedHTML, p.EmbedVideoURL,
|
|
).Scan(
|
|
&row.ID, &pubID, &row.Title, &row.Description, &row.LinkURL,
|
|
&row.ItemType, &row.EmbedProvider, &row.EmbedVideoID, &row.EmbedHTML, &row.EmbedVideoURL,
|
|
&row.CreatedAt, &row.DeletedAt,
|
|
)
|
|
if err == nil {
|
|
row.PubID = formatUUID(pubID)
|
|
}
|
|
return row, err
|
|
}
|
|
|
|
// QFindByPubID finds an item by its public ID.
|
|
func QFindByPubID(ctx context.Context, db *sql.DB, pubID string) (*Row, error) {
|
|
query := `
|
|
SELECT id, pub_id, title, description, link_url, item_type, embed_provider, embed_video_id, embed_html, embed_video_url, created_at, deleted_at
|
|
FROM item
|
|
WHERE pub_id = $1
|
|
`
|
|
|
|
var row Row
|
|
var pubUUID pgtype.UUID
|
|
err := db.QueryRowContext(ctx, query, pubID).Scan(
|
|
&row.ID, &pubUUID, &row.Title, &row.Description, &row.LinkURL,
|
|
&row.ItemType, &row.EmbedProvider, &row.EmbedVideoID, &row.EmbedHTML, &row.EmbedVideoURL,
|
|
&row.CreatedAt, &row.DeletedAt,
|
|
)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
row.PubID = formatUUID(pubUUID)
|
|
return &row, nil
|
|
}
|
|
|
|
// QFindByID finds an item by its internal ID.
|
|
func QFindByID(ctx context.Context, db *sql.DB, id int64) (*Row, error) {
|
|
query := `
|
|
SELECT id, pub_id, title, description, link_url, item_type, embed_provider, embed_video_id, embed_html, embed_video_url, created_at, deleted_at
|
|
FROM item
|
|
WHERE id = $1
|
|
`
|
|
|
|
var row Row
|
|
var pubUUID pgtype.UUID
|
|
err := db.QueryRowContext(ctx, query, id).Scan(
|
|
&row.ID, &pubUUID, &row.Title, &row.Description, &row.LinkURL,
|
|
&row.ItemType, &row.EmbedProvider, &row.EmbedVideoID, &row.EmbedHTML, &row.EmbedVideoURL,
|
|
&row.CreatedAt, &row.DeletedAt,
|
|
)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
row.PubID = formatUUID(pubUUID)
|
|
return &row, nil
|
|
}
|
|
|
|
// QList returns all non-deleted items, newest first.
|
|
func QList(ctx context.Context, db *sql.DB) ([]Row, error) {
|
|
query := `
|
|
SELECT id, pub_id, title, description, link_url, item_type, embed_provider, embed_video_id, embed_html, embed_video_url, created_at, deleted_at
|
|
FROM item
|
|
WHERE deleted_at IS NULL
|
|
ORDER BY created_at DESC
|
|
`
|
|
|
|
rows, err := db.QueryContext(ctx, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var items []Row
|
|
for rows.Next() {
|
|
var row Row
|
|
var pubUUID pgtype.UUID
|
|
if err := rows.Scan(
|
|
&row.ID, &pubUUID, &row.Title, &row.Description, &row.LinkURL,
|
|
&row.ItemType, &row.EmbedProvider, &row.EmbedVideoID, &row.EmbedHTML, &row.EmbedVideoURL,
|
|
&row.CreatedAt, &row.DeletedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
row.PubID = formatUUID(pubUUID)
|
|
items = append(items, row)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
// QListByTag returns all non-deleted items with a specific tag, newest first.
|
|
func QListByTag(ctx context.Context, db *sql.DB, tagName string) ([]Row, error) {
|
|
query := `
|
|
SELECT i.id, i.pub_id, i.title, i.description, i.link_url, i.item_type, i.embed_provider, i.embed_video_id, i.embed_html, i.embed_video_url, i.created_at, i.deleted_at
|
|
FROM item i
|
|
JOIN item_tag it ON i.id = it.item_id
|
|
JOIN tag t ON it.tag_id = t.id
|
|
WHERE i.deleted_at IS NULL AND t.name = $1
|
|
ORDER BY i.created_at DESC
|
|
`
|
|
|
|
rows, err := db.QueryContext(ctx, query, tagName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var items []Row
|
|
for rows.Next() {
|
|
var row Row
|
|
var pubUUID pgtype.UUID
|
|
if err := rows.Scan(
|
|
&row.ID, &pubUUID, &row.Title, &row.Description, &row.LinkURL,
|
|
&row.ItemType, &row.EmbedProvider, &row.EmbedVideoID, &row.EmbedHTML, &row.EmbedVideoURL,
|
|
&row.CreatedAt, &row.DeletedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
row.PubID = formatUUID(pubUUID)
|
|
items = append(items, row)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
type UpdateParams struct {
|
|
Title sql.Null[string]
|
|
Description sql.Null[string]
|
|
LinkURL sql.Null[string]
|
|
}
|
|
|
|
// QUpdate updates an item's editable fields.
|
|
func QUpdate(ctx context.Context, db *sql.DB, id int64, p UpdateParams) error {
|
|
query := `
|
|
UPDATE item
|
|
SET title = $2, description = $3, link_url = $4
|
|
WHERE id = $1
|
|
`
|
|
_, err := db.ExecContext(ctx, query, id, p.Title, p.Description, p.LinkURL)
|
|
return err
|
|
}
|
|
|
|
// QUpdateType updates an item's type.
|
|
func QUpdateType(ctx context.Context, db *sql.DB, id int64, itemType string) error {
|
|
query := `UPDATE item SET item_type = $2 WHERE id = $1`
|
|
_, err := db.ExecContext(ctx, query, id, itemType)
|
|
return err
|
|
}
|
|
|
|
// QUpdateVideoURL updates an item's embed video URL.
|
|
func QUpdateVideoURL(ctx context.Context, db *sql.DB, id int64, videoURL string) error {
|
|
query := `UPDATE item SET embed_video_url = $2 WHERE id = $1`
|
|
_, err := db.ExecContext(ctx, query, id, videoURL)
|
|
return err
|
|
}
|
|
|
|
// QSoftDelete soft deletes an item.
|
|
func QSoftDelete(ctx context.Context, db *sql.DB, id int64) error {
|
|
query := `UPDATE item SET deleted_at = NOW() WHERE id = $1`
|
|
_, err := db.ExecContext(ctx, query, id)
|
|
return err
|
|
}
|
|
|
|
// QRestore restores a soft-deleted item.
|
|
func QRestore(ctx context.Context, db *sql.DB, id int64) error {
|
|
query := `UPDATE item SET deleted_at = NULL WHERE id = $1`
|
|
_, err := db.ExecContext(ctx, query, id)
|
|
return err
|
|
}
|
|
|
|
func formatUUID(u pgtype.UUID) string {
|
|
if !u.Valid {
|
|
return ""
|
|
}
|
|
b := u.Bytes
|
|
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
|
}
|