Files
mule-image/sidecar/handlers_marks.go
dtoro ccf2c6b7c7 fix: bulk label apply, instant archive removal, per-photo state, drop sidebar counts
Issue 1 — colors/labels not applying in bulk:
- sidecar validColors only accepted 4 of the 8 UI swatches, so teal/blue/
  purple/pink returned "invalid color" and rolled back the whole bulk txn.
  Add teal, blue, purple, pink to validColors.
- Add invalidateFacets() and call it on the success path of bulk marks,
  patchTargets, and single-photo edits so the Colors/Ratings/Notes facet
  sections refresh immediately instead of waiting out staleTime.

Issue 2 — archived photos linger in the grid:
- Add a UI-only removedIds set to the bulkAction store; archive/delete/
  restore/keep call markRemoved() on success so tiles vanish instantly,
  cleared once the server-reconcile refetch lands (no cache eviction).

Issue 3 — per-photo progress state:
- Wire startBulk/doneBulk/failBulk into all metadata applies, bulk
  (BulkMetadataSidebar) and single (RightSidebar), so colors/ratings/
  notes/dates/keywords show the spinner -> check -> X overlay.

Issue 4 — remove Left-sidebar count badges:
- Drop count badges from root folder, Archive, heaps, Notes, and the
  folder tree, plus the now-dead count queries and unused imports. Facet
  drill-panel counts are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 00:21:04 +02:00

213 lines
5.4 KiB
Go

package main
import (
"errors"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// validColors is the color palette the web client offers (COLOR_SWATCHES in
// web/src/lib/utils/tagGroups.ts) — keep the two in sync. The empty string is
// the explicit "clear color" sentinel.
var validColors = map[string]struct{}{
"red": {},
"orange": {},
"yellow": {},
"green": {},
"teal": {},
"blue": {},
"purple": {},
"pink": {},
}
// markPatch is the request body for all three mutating mark endpoints.
// Pointers distinguish "field omitted" from "field set to zero" — a PUT
// with `{"rating": 0}` clears the rating, but a PUT with `{"color": "red"}`
// alone must NOT wipe an existing rating.
type markPatch struct {
Rating *int `json:"rating,omitempty"`
Color *string `json:"color,omitempty"`
}
func (p *markPatch) sanitize() error {
if p.Rating != nil {
r := *p.Rating
if r < 0 || r > 5 {
return errors.New("rating out of range")
}
}
if p.Color != nil {
c := strings.ToLower(strings.TrimSpace(*p.Color))
if c != "" {
if _, ok := validColors[c]; !ok {
return errors.New("invalid color")
}
}
*p.Color = c
}
return nil
}
// apply merges the patch onto an existing row (or a fresh zero-value
// Mark for an upsert). Returns true if anything in the row still has a
// non-empty value — false signals "delete the row" to the caller.
func (p *markPatch) apply(m *Mark) bool {
if p.Rating != nil {
if *p.Rating > 0 {
r := *p.Rating
m.Rating = &r
} else {
m.Rating = nil
}
}
if p.Color != nil {
if *p.Color != "" {
c := *p.Color
m.Color = &c
} else {
m.Color = nil
}
}
return m.Rating != nil || (m.Color != nil && *m.Color != "")
}
// allMarksJSON renders the current user's marks as the wire shape
// `{"<uid>": {"rating": …, "color": …, "updatedAt": …}, …}`. Used by
// GET /photos/marks which the web client calls once on session start.
func allMarksJSON(db *gorm.DB, userName string) (map[string]map[string]any, error) {
var rows []Mark
if err := db.Where("user_name = ?", userName).Find(&rows).Error; err != nil {
return nil, err
}
out := make(map[string]map[string]any, len(rows))
for i := range rows {
out[rows[i].PhotoUID] = rows[i].asJSON()
}
return out, nil
}
func handleMarksAll(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
marks, err := allMarksJSON(db, ctxUserName(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, marks)
}
}
func handleMarkGet(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("uid")
var m Mark
err := db.Where("photo_uid = ? AND user_name = ?", uid, ctxUserName(c)).First(&m).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusOK, gin.H{})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, m.asJSON())
}
}
// upsert applies the patch and writes back. Returns the resulting JSON
// shape (empty map if the row was deleted).
func upsert(db *gorm.DB, uid, userName string, patch *markPatch) (map[string]any, error) {
var m Mark
err := db.Where("photo_uid = ? AND user_name = ?", uid, userName).First(&m).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
m.PhotoUID = uid
m.UserName = userName
keep := patch.apply(&m)
m.UpdatedAt = time.Now().UTC()
if !keep {
if err := db.Where("photo_uid = ? AND user_name = ?", uid, userName).Delete(&Mark{}).Error; err != nil {
return nil, err
}
return map[string]any{}, nil
}
if err := db.Save(&m).Error; err != nil {
return nil, err
}
return m.asJSON(), nil
}
func handleMarkPut(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("uid")
var patch markPatch
if err := c.ShouldBindJSON(&patch); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid patch"})
return
}
if err := patch.sanitize(); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
out, err := upsert(db, uid, ctxUserName(c), &patch)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, out)
}
}
type bulkBody struct {
IDs []string `json:"ids"`
Patch markPatch `json:"patch"`
}
func handleMarkBulk(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var body bulkBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
if len(body.IDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ids[] required"})
return
}
if err := body.Patch.sanitize(); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userName := ctxUserName(c)
applied := make(map[string]map[string]any, len(body.IDs))
// Single transaction so a partial failure rolls back. The client
// expects atomic semantics for a bulk star/colour stamp.
err := db.Transaction(func(tx *gorm.DB) error {
for _, uid := range body.IDs {
if uid == "" {
continue
}
out, err := upsert(tx, uid, userName, &body.Patch)
if err != nil {
return err
}
applied[uid] = out
}
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"count": len(applied),
"marks": applied,
})
}
}