- backend: soft delete + trash (photos.deleted_at, migration 000005), GET /trash, restore, permanent delete with storage-key refcount (S3 objects removed only when no other album references them); batch endpoints photo-batch/{delete,move,copy} - metadata only, copies clone rows sharing storage keys
- frontend: Ctrl/Shift multiselect; right-panel 'Действия' as icon text rows (Открыть (просмотр), Переместить, Копировать, Удалить); drag selected photos onto rail album -> TransferDialog; /trash route; trash entry as last item in the left rail; empty right panel hidden on overview
- frontend: compact top bars (app header and viewer), compact theme switcher
- openspec: sync lightroom-workspace-ui artifacts (photos/ui-workspace specs, design #20-24, tasks 9.11-9.17)
317 lines
9.3 KiB
Go
317 lines
9.3 KiB
Go
package photos
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"shared-album/internal/models"
|
|
)
|
|
|
|
var ErrNotFound = errors.New("photo not found")
|
|
|
|
type Store struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
|
|
|
|
const photoColumns = `p.id, p.album_id, p.uploader_id, p.storage_key,
|
|
COALESCE(p.thumb_key, ''), COALESCE(p.preview_key, ''),
|
|
p.original_filename, p.size, p.width, p.height, p.mime_type,
|
|
p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at,
|
|
u.id, u.name, COALESCE(u.avatar_url, ''),
|
|
(SELECT count(*) FROM likes l WHERE l.photo_id = p.id),
|
|
(SELECT count(*) FROM comments c WHERE c.photo_id = p.id),
|
|
EXISTS(SELECT 1 FROM likes l WHERE l.photo_id = p.id AND l.user_id = $2),
|
|
COALESCE(p.exif, '{}'::jsonb)::text, p.deleted_at`
|
|
|
|
func scanPhoto(row pgx.Row) (*models.Photo, error) {
|
|
var p models.Photo
|
|
var exifText string
|
|
err := row.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
|
&p.ThumbKey, &p.PreviewKey,
|
|
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
|
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
|
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
|
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
setExif(&p, exifText)
|
|
return &p, nil
|
|
}
|
|
|
|
func setExif(p *models.Photo, raw string) {
|
|
if raw == "" || raw == "null" || raw == "{}" {
|
|
return
|
|
}
|
|
var m map[string]any
|
|
if err := json.Unmarshal([]byte(raw), &m); err == nil && len(m) > 0 {
|
|
p.EXIF = m
|
|
}
|
|
}
|
|
|
|
func (s *Store) Create(ctx context.Context, p *models.Photo) error {
|
|
exifJSON, err := json.Marshal(p.EXIF)
|
|
if err != nil || len(exifJSON) == 0 {
|
|
exifJSON = []byte("{}")
|
|
}
|
|
return s.pool.QueryRow(ctx,
|
|
`INSERT INTO photos (id, album_id, uploader_id, storage_key, original_filename,
|
|
size, width, height, mime_type, taken_at, latitude, longitude, caption, exif)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb)
|
|
RETURNING created_at`,
|
|
p.ID, p.AlbumID, p.UploaderID, p.StorageKey, p.OriginalFilename,
|
|
p.Size, p.Width, p.Height, p.MimeType, p.TakenAt, p.Latitude, p.Longitude, p.Caption, string(exifJSON),
|
|
).Scan(&p.CreatedAt)
|
|
}
|
|
|
|
func (s *Store) SetVariants(ctx context.Context, id, thumbKey, previewKey string) error {
|
|
_, err := s.pool.Exec(ctx,
|
|
`UPDATE photos SET thumb_key = $2, preview_key = $3 WHERE id = $1`, id, thumbKey, previewKey)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) GetByID(ctx context.Context, id, viewerID string) (*models.Photo, error) {
|
|
return scanPhoto(s.pool.QueryRow(ctx,
|
|
`SELECT `+photoColumns+`
|
|
FROM photos p JOIN users u ON u.id = p.uploader_id
|
|
WHERE p.id = $1`, id, viewerID))
|
|
}
|
|
|
|
type ListParams struct {
|
|
AlbumID string
|
|
Viewer string
|
|
Cursor string
|
|
Since *time.Time
|
|
Limit int
|
|
}
|
|
|
|
func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error) {
|
|
if in.Limit <= 0 || in.Limit > 100 {
|
|
in.Limit = 50
|
|
}
|
|
rows, err := s.pool.Query(ctx,
|
|
`SELECT `+photoColumns+`
|
|
FROM photos p JOIN users u ON u.id = p.uploader_id
|
|
WHERE p.album_id = $1
|
|
AND p.deleted_at IS NULL
|
|
AND ($3 = '' OR (p.created_at, p.id) < (SELECT created_at, id FROM photos WHERE id = $3))
|
|
AND ($4::timestamptz IS NULL OR p.created_at > $4)
|
|
ORDER BY p.created_at DESC, p.id DESC
|
|
LIMIT $5`,
|
|
in.AlbumID, in.Viewer, in.Cursor, in.Since, in.Limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []models.Photo{}
|
|
for rows.Next() {
|
|
var p models.Photo
|
|
var exifText string
|
|
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
|
&p.ThumbKey, &p.PreviewKey,
|
|
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
|
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
|
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
|
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
setExif(&p, exifText)
|
|
out = append(out, p)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *Store) SetDeleted(ctx context.Context, id string, deleted bool) error {
|
|
var tag pgconn.CommandTag
|
|
var err error
|
|
if deleted {
|
|
tag, err = s.pool.Exec(ctx,
|
|
`UPDATE photos SET deleted_at = now() WHERE id = $1 AND deleted_at IS NULL`, id)
|
|
} else {
|
|
tag, err = s.pool.Exec(ctx,
|
|
`UPDATE photos SET deleted_at = NULL WHERE id = $1`, id)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) UpdateAlbum(ctx context.Context, id, albumID string) error {
|
|
tag, err := s.pool.Exec(ctx,
|
|
`UPDATE photos SET album_id = $2 WHERE id = $1 AND deleted_at IS NULL`, id, albumID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Clone creates a new photo row that references the same stored objects as src.
|
|
func (s *Store) Clone(ctx context.Context, src *models.Photo, albumID, uploaderID, id string) error {
|
|
exifJSON, err := json.Marshal(src.EXIF)
|
|
if err != nil || len(exifJSON) == 0 {
|
|
exifJSON = []byte("{}")
|
|
}
|
|
_, err = s.pool.Exec(ctx,
|
|
`INSERT INTO photos (id, album_id, uploader_id, storage_key, thumb_key, preview_key,
|
|
original_filename, size, width, height, mime_type, taken_at, latitude, longitude, caption, exif)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16::jsonb)`,
|
|
id, albumID, uploaderID, src.StorageKey, nullIfEmpty(src.ThumbKey), nullIfEmpty(src.PreviewKey),
|
|
src.OriginalFilename, src.Size, src.Width, src.Height, src.MimeType,
|
|
src.TakenAt, src.Latitude, src.Longitude, src.Caption, string(exifJSON))
|
|
return err
|
|
}
|
|
|
|
// StorageKeyRefCount counts photo rows (any state) referencing the stored object.
|
|
func (s *Store) StorageKeyRefCount(ctx context.Context, storageKey string) (int, error) {
|
|
var n int
|
|
err := s.pool.QueryRow(ctx, `SELECT count(*) FROM photos WHERE storage_key = $1`, storageKey).Scan(&n)
|
|
return n, err
|
|
}
|
|
|
|
// HardDelete permanently removes the photo row.
|
|
func (s *Store) HardDelete(ctx context.Context, id string) error {
|
|
tag, err := s.pool.Exec(ctx, `DELETE FROM photos WHERE id = $1`, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListTrash returns soft-deleted photos from albums the user belongs to.
|
|
func (s *Store) ListTrash(ctx context.Context, userID string) ([]models.Photo, error) {
|
|
rows, err := s.pool.Query(ctx,
|
|
`SELECT `+photoColumns+`, a.title
|
|
FROM photos p
|
|
JOIN users u ON u.id = p.uploader_id
|
|
JOIN albums a ON a.id = p.album_id
|
|
WHERE p.deleted_at IS NOT NULL
|
|
AND (a.owner_id = $1 OR EXISTS(
|
|
SELECT 1 FROM album_members m WHERE m.album_id = a.id AND m.user_id = $1))
|
|
ORDER BY p.deleted_at DESC
|
|
LIMIT 200`,
|
|
userID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []models.Photo{}
|
|
for rows.Next() {
|
|
var p models.Photo
|
|
var exifText string
|
|
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
|
&p.ThumbKey, &p.PreviewKey,
|
|
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
|
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
|
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
|
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &p.AlbumTitle); err != nil {
|
|
return nil, err
|
|
}
|
|
setExif(&p, exifText)
|
|
out = append(out, p)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func nullIfEmpty(s string) *string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return &s
|
|
}
|
|
|
|
func (s *Store) SetCoverIfEmpty(ctx context.Context, albumID, photoID string) {
|
|
_, _ = s.pool.Exec(ctx,
|
|
`UPDATE albums SET cover_photo_id = $2 WHERE id = $1 AND cover_photo_id IS NULL`,
|
|
albumID, photoID)
|
|
}
|
|
|
|
func (s *Store) UpdateFilename(ctx context.Context, id, filename string) error {
|
|
tag, err := s.pool.Exec(ctx, `UPDATE photos SET original_filename = $2 WHERE id = $1`, id, filename)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type backfillRow struct {
|
|
ID string
|
|
StorageKey string
|
|
}
|
|
|
|
func (s *Store) BackfillEXIF(ctx context.Context, fetch func(context.Context, string) ([]byte, error)) (int, error) {
|
|
rows, err := s.pool.Query(ctx,
|
|
`SELECT id, storage_key FROM photos
|
|
WHERE exif IS NULL OR exif = '{}'::jsonb
|
|
ORDER BY created_at`)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
var pending []backfillRow
|
|
for rows.Next() {
|
|
var r backfillRow
|
|
if err := rows.Scan(&r.ID, &r.StorageKey); err != nil {
|
|
rows.Close()
|
|
return 0, err
|
|
}
|
|
pending = append(pending, r)
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
updated := 0
|
|
for _, r := range pending {
|
|
data, err := fetch(ctx, r.StorageKey)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
meta := extractEXIF(data)
|
|
if len(meta.Attributes) == 0 && meta.TakenAt == nil && meta.Latitude == nil && meta.Longitude == nil {
|
|
continue
|
|
}
|
|
attrs, err := json.Marshal(meta.Attributes)
|
|
if err != nil || len(attrs) == 0 {
|
|
attrs = []byte("{}")
|
|
}
|
|
_, err = s.pool.Exec(ctx,
|
|
`UPDATE photos SET exif = $2::jsonb,
|
|
taken_at = COALESCE(taken_at, $3),
|
|
latitude = COALESCE(latitude, $4),
|
|
longitude = COALESCE(longitude, $5)
|
|
WHERE id = $1`,
|
|
r.ID, string(attrs), meta.TakenAt, meta.Latitude, meta.Longitude)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
updated++
|
|
}
|
|
return updated, nil
|
|
}
|