Batch: Add helpful Items receivers to values_item.go

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-11-20 16:20:17 +01:00
parent fa3aba1667
commit ad2470ca04
3 changed files with 268 additions and 15 deletions

View File

@@ -4,21 +4,6 @@ import (
"time" "time"
) )
// Items represents batch edit value items.
type Items struct {
Items []Item `json:"items"`
Mixed bool `json:"mixed"`
Action Action `json:"action"`
}
// Item represents batch edit value item.
type Item struct {
Value string `json:"value"`
Title string `json:"title"`
Mixed bool `json:"mixed"`
Action Action `json:"action"`
}
// String represents batch edit form value. // String represents batch edit form value.
type String struct { type String struct {
Value string `json:"value"` Value string `json:"value"`

View File

@@ -0,0 +1,128 @@
package batch
// Item represents batch edit value item.
type Item struct {
Value string `json:"value"`
Title string `json:"title"`
Mixed bool `json:"mixed"`
Action Action `json:"action"`
}
// Items represents batch edit value items.
type Items struct {
Items []Item `json:"items"`
Mixed bool `json:"mixed"`
Action Action `json:"action"`
}
// ResolveValuesByTitle replaces empty values with resolver results so callers
// can pre-create referenced entities (for example albums) once per unique
// title instead of repeating the same lookup for every photo.
func (it *Items) ResolveValuesByTitle(resolver func(title, action string) string) {
if it == nil || resolver == nil || len(it.Items) == 0 {
return
}
type cacheKey struct {
title string
action Action
}
cache := make(map[cacheKey]string)
for i := range it.Items {
item := &it.Items[i]
if item.Value != "" || item.Title == "" {
continue
}
key := cacheKey{title: item.Title, action: item.Action}
if val, ok := cache[key]; ok {
if val != "" {
item.Value = val
}
continue
}
resolved := resolver(item.Title, item.Action)
cache[key] = resolved
if resolved != "" {
item.Value = resolved
}
}
}
// GetValuesByActions returns the non-empty values for items whose action is
// included in the provided filter. The original ordering is preserved so the
// caller can correlate results with the source payload.
func (it *Items) GetValuesByActions(actions []Action) []string {
if it == nil || len(it.Items) == 0 {
return nil
}
actionFilter := actionSet(actions)
if len(actionFilter) == 0 {
return nil
}
values := make([]string, 0, len(it.Items))
for _, item := range it.Items {
if _, ok := actionFilter[item.Action]; !ok {
continue
}
if item.Value == "" {
continue
}
values = append(values, item.Value)
}
if len(values) == 0 {
return nil
}
return values
}
// GetItemsByActions returns all items whose action matches any entry in the
// provided filter, preserving their original order.
func (it *Items) GetItemsByActions(actions []Action) []Item {
if it == nil || len(it.Items) == 0 {
return nil
}
actionFilter := actionSet(actions)
if len(actionFilter) == 0 {
return nil
}
filtered := make([]Item, 0, len(it.Items))
for _, item := range it.Items {
if _, ok := actionFilter[item.Action]; ok {
filtered = append(filtered, item)
}
}
if len(filtered) == 0 {
return nil
}
return filtered
}
func actionSet(actions []Action) map[Action]struct{} {
if len(actions) == 0 {
return nil
}
m := make(map[Action]struct{}, len(actions))
for _, act := range actions {
m[act] = struct{}{}
}
return m
}

View File

@@ -0,0 +1,140 @@
package batch
import (
"reflect"
"testing"
)
func TestItemsGetValuesByActions(t *testing.T) {
t.Parallel()
items := Items{
Items: []Item{
{Value: "uid-add", Action: ActionAdd},
{Value: "uid-remove", Action: ActionRemove},
{Value: "", Action: ActionAdd},
{Value: "uid-update", Action: ActionUpdate},
},
}
want := []string{"uid-add", "uid-remove"}
got := items.GetValuesByActions([]Action{ActionAdd, ActionRemove})
if !reflect.DeepEqual(got, want) {
t.Fatalf("GetValuesByActions() = %v, want %v", got, want)
}
if vals := items.GetValuesByActions(nil); vals != nil {
t.Fatalf("expected nil slice when no actions provided, got %v", vals)
}
var empty *Items
if vals := empty.GetValuesByActions([]Action{ActionAdd}); vals != nil {
t.Fatalf("expected nil slice for nil receiver, got %v", vals)
}
}
func TestItemsGetItemsByActions(t *testing.T) {
t.Parallel()
items := Items{
Items: []Item{
{Value: "uid-add", Title: "Add", Action: ActionAdd},
{Value: "uid-remove", Title: "Remove", Action: ActionRemove},
{Value: "uid-skip", Title: "Skip", Action: ActionNone},
},
}
want := []Item{
{Value: "uid-add", Title: "Add", Action: ActionAdd},
{Value: "uid-remove", Title: "Remove", Action: ActionRemove},
}
got := items.GetItemsByActions([]Action{ActionAdd, ActionRemove})
if !reflect.DeepEqual(got, want) {
t.Fatalf("GetItemsByActions() = %v, want %v", got, want)
}
if filtered := items.GetItemsByActions([]Action{ActionUpdate}); filtered != nil {
t.Fatalf("expected nil when no items match, got %v", filtered)
}
var nilItems *Items
if filtered := nilItems.GetItemsByActions([]Action{ActionAdd}); filtered != nil {
t.Fatalf("expected nil for nil receiver, got %v", filtered)
}
}
func TestItemsResolveValuesByTitle(t *testing.T) {
t.Parallel()
items := Items{
Items: []Item{
{Title: "Trips", Action: ActionRemove},
{Title: "Trips", Action: ActionAdd},
{Title: "Trips", Action: ActionAdd},
{Title: "Archived", Value: "album-arch", Action: ActionAdd},
{Title: "", Action: ActionAdd},
{Title: "Drafts", Action: ActionAdd},
{Title: "Trips", Action: ActionRemove},
{Title: "Trips", Action: ActionUpdate},
},
}
var callLog []string
resolver := func(title, action string) string {
callLog = append(callLog, title+":"+action)
if action != ActionAdd {
return ""
}
resolved := map[string]string{
"Trips": "album-trips",
"Drafts": "",
}[title]
return resolved
}
items.ResolveValuesByTitle(resolver)
expectedCalls := []string{"Trips:remove", "Trips:add", "Drafts:add", "Trips:update"}
if !reflect.DeepEqual(callLog, expectedCalls) {
t.Fatalf("unexpected resolver calls %v, want %v", callLog, expectedCalls)
}
if got := items.Items[0].Value; got != "" {
t.Fatalf("expected remove action to remain empty, got %q", got)
}
if got := items.Items[1].Value; got != "album-trips" {
t.Fatalf("expected first add action to resolve value, got %q", got)
}
if got := items.Items[2].Value; got != "album-trips" {
t.Fatalf("expected duplicate add to reuse cached value, got %q", got)
}
if got := items.Items[3].Value; got != "album-arch" {
t.Fatalf("expected existing value to remain, got %q", got)
}
if got := items.Items[4].Value; got != "" {
t.Fatalf("expected empty title to remain untouched, got %q", got)
}
if got := items.Items[5].Value; got != "" {
t.Fatalf("expected Drafts resolver empty result, got %q", got)
}
if got := items.Items[6].Value; got != "" {
t.Fatalf("expected cached remove action to stay empty, got %q", got)
}
if got := items.Items[7].Value; got != "" {
t.Fatalf("expected update action to stay empty, got %q", got)
}
items.ResolveValuesByTitle(nil)
var nilItems *Items
nilItems.ResolveValuesByTitle(func(string, string) string { t.Fatal("should not be called"); return "" })
}