mirror of
https://github.com/photoprism/photoprism.git
synced 2026-01-23 02:24:24 +00:00
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
parent
de0500369f
commit
a959ea5eae
17 changed files with 1555 additions and 174 deletions
|
|
@ -644,7 +644,9 @@ export default {
|
|||
},
|
||||
computed: {
|
||||
formTitle() {
|
||||
return this.$gettext(`Edit Photos (%{n})`, { n: this.allSelectedLength });
|
||||
// TODO: this.allSelectedLength should not include photos that could not be loaded e.g. because they are archived.
|
||||
const n = Number(this.allSelectedLength) > this.getModelCount() ? this.getModelCount() : 0;
|
||||
return this.$gettext(`Edit Photos (%{n})`, { n });
|
||||
},
|
||||
currentCoordinates() {
|
||||
if (this.isLocationMixed || this.deletedFields.Lat || this.deletedFields.Lng) {
|
||||
|
|
@ -799,6 +801,9 @@ export default {
|
|||
this.allSelectedLength = 0;
|
||||
this.$view.leave(this);
|
||||
},
|
||||
getModelCount() {
|
||||
return this.model?.models?.length ? this.model.models.length : 0;
|
||||
},
|
||||
normalizeLabelTitleForCompare(s) {
|
||||
return $util.normalizeLabelTitle(s);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/dustin/go-humanize/english"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/ulule/deepcopier"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/auth/acl"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
|
|
@ -30,12 +28,19 @@ import (
|
|||
// @Router /api/v1/batch/photos/edit [post]
|
||||
func BatchPhotosEdit(router *gin.RouterGroup) {
|
||||
router.Match(MethodsPutPost, "/batch/photos/edit", func(c *gin.Context) {
|
||||
s := Auth(c, acl.ResourcePhotos, acl.ActionUpdate)
|
||||
// Require access to all photos.
|
||||
s := Auth(c, acl.ResourcePhotos, acl.AccessAll)
|
||||
|
||||
if s.Abort(c) {
|
||||
return
|
||||
}
|
||||
|
||||
// Require update permissions for photos.
|
||||
if acl.Rules.Deny(acl.ResourcePhotos, s.GetUserRole(), acl.ActionUpdate) {
|
||||
AbortForbidden(c)
|
||||
return
|
||||
}
|
||||
|
||||
var frm batch.PhotosRequest
|
||||
|
||||
// Assign and validate request form values.
|
||||
|
|
@ -56,82 +61,83 @@ func BatchPhotosEdit(router *gin.RouterGroup) {
|
|||
|
||||
// Abort if no photos were found.
|
||||
if err != nil {
|
||||
log.Errorf("batch: %s", clean.Error(err))
|
||||
log.Errorf("batch: %s (load selection)", clean.Error(err))
|
||||
AbortUnexpectedError(c)
|
||||
return
|
||||
}
|
||||
|
||||
// Update photo metadata based on submitted form values.
|
||||
if frm.Values != nil {
|
||||
log.Debugf("batch: updating photo metadata for %d photos", len(photos))
|
||||
updatedCount := 0
|
||||
preloadedPhotos := map[string]*entity.Photo{}
|
||||
|
||||
for i, photo := range photos {
|
||||
photoID := photo.PhotoUID
|
||||
|
||||
// Get the full photo entity with preloaded data
|
||||
fullPhoto, err := query.PhotoPreloadByUID(photoID)
|
||||
if err != nil {
|
||||
log.Errorf("batch: failed to load photo %s: %s", photoID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Convert batch form to regular photo form
|
||||
photoForm, err := batch.ConvertToPhotoForm(&fullPhoto, frm.Values)
|
||||
if err != nil {
|
||||
log.Errorf("batch: failed to convert form for photo %s: %s", photoID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Use the same save mechanism as normal edit
|
||||
if err := entity.SavePhotoForm(&fullPhoto, *photoForm); err != nil {
|
||||
log.Errorf("batch: failed to save photo %s: %s", photoID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Apply Albums updates if requested
|
||||
if frm.Values.Albums.Action == batch.ActionUpdate {
|
||||
if err := batch.ApplyAlbums(photoID, frm.Values.Albums); err != nil {
|
||||
log.Errorf("batch: failed to update albums for photo %s: %s", photoID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply Labels updates if requested
|
||||
if frm.Values.Labels.Action == batch.ActionUpdate {
|
||||
if err := batch.ApplyLabels(&fullPhoto, frm.Values.Labels); err != nil {
|
||||
log.Errorf("batch: failed to update labels for photo %s: %s", photoID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the updated entity.Photo back to search.Photo and update the results array
|
||||
updatedSearchPhoto, convertErr := convertEntityToSearchPhoto(&fullPhoto)
|
||||
if convertErr != nil {
|
||||
log.Errorf("batch: failed to convert photo %s to search result: %s", photoID, convertErr)
|
||||
} else {
|
||||
photos[i] = *updatedSearchPhoto
|
||||
}
|
||||
updatedCount++
|
||||
|
||||
// Save sidecar YAML if enabled
|
||||
SaveSidecarYaml(&fullPhoto)
|
||||
|
||||
log.Debugf("batch: successfully updated photo %s", photoID)
|
||||
}
|
||||
|
||||
log.Infof("batch: successfully updated %d out of %d photos", updatedCount, len(photos))
|
||||
|
||||
// Publish photo update events
|
||||
for _, photo := range photos {
|
||||
PublishPhotoEvent(StatusUpdated, photo.PhotoUID, c)
|
||||
}
|
||||
|
||||
// Update client config and flush cache
|
||||
UpdateClientConfig()
|
||||
FlushCoverCache()
|
||||
if hydrated, err := query.PhotoPreloadByUIDs(photos.UIDs()); err != nil {
|
||||
log.Errorf("batch: failed to preload photo selection: %s", err)
|
||||
AbortUnexpectedError(c)
|
||||
return
|
||||
} else {
|
||||
preloadedPhotos = mapPhotosByUID(hydrated)
|
||||
}
|
||||
|
||||
// Create batch edit form values form from photo metadata.
|
||||
batchFrm := batch.NewPhotosForm(photos)
|
||||
var (
|
||||
saveRequests []*batch.PhotoSaveRequest
|
||||
saveResults []bool
|
||||
savedAny bool
|
||||
)
|
||||
|
||||
if frm.Values != nil {
|
||||
outcome, saveErr := batch.PrepareAndSavePhotos(photos, preloadedPhotos, frm.Values)
|
||||
|
||||
if saveErr != nil {
|
||||
log.Errorf("batch: failed to persist photo updates: %s", saveErr)
|
||||
AbortUnexpectedError(c)
|
||||
return
|
||||
}
|
||||
|
||||
saveRequests = outcome.Requests
|
||||
saveResults = outcome.Results
|
||||
preloadedPhotos = outcome.Preloaded
|
||||
savedAny = outcome.SavedAny
|
||||
}
|
||||
|
||||
// Refresh the selection once so Albums / Labels reflect the committed state when we
|
||||
// build the form that is returned to the client.
|
||||
refreshedPhotos := preloadedPhotos
|
||||
if hydrated, err := query.PhotoPreloadByUIDs(photos.UIDs()); err != nil {
|
||||
log.Warnf("batch: failed to refresh photos for response: %s", err)
|
||||
} else {
|
||||
refreshedPhotos = mapPhotosByUID(hydrated)
|
||||
}
|
||||
|
||||
// Refresh selected photos from database?
|
||||
if !savedAny {
|
||||
// Don't refresh.
|
||||
} else if photos, count, err = search.BatchPhotos(frm.Photos, s); err != nil {
|
||||
log.Errorf("batch: %s (refresh selection)", clean.Error(err))
|
||||
}
|
||||
|
||||
// Create batch edit form values form from photo metadata using the refreshed entities so
|
||||
// the response reflects persisted album/label edits without issuing per-photo queries.
|
||||
batchFrm := batch.NewPhotosFormWithEntities(photos, refreshedPhotos)
|
||||
|
||||
if len(saveResults) > 0 {
|
||||
for i, saved := range saveResults {
|
||||
if !saved {
|
||||
continue
|
||||
}
|
||||
|
||||
photo := refreshedPhotos[saveRequests[i].Photo.PhotoUID]
|
||||
|
||||
if photo == nil {
|
||||
photo = saveRequests[i].Photo
|
||||
}
|
||||
|
||||
// PublishPhotoEvent(StatusUpdated, photo.PhotoUID, c)
|
||||
SaveSidecarYaml(photo)
|
||||
}
|
||||
|
||||
if savedAny {
|
||||
UpdateClientConfig()
|
||||
FlushCoverCache()
|
||||
}
|
||||
}
|
||||
|
||||
// Return models and form values.
|
||||
data := batch.PhotosResponse{
|
||||
|
|
@ -143,23 +149,15 @@ func BatchPhotosEdit(router *gin.RouterGroup) {
|
|||
})
|
||||
}
|
||||
|
||||
// convertEntityToSearchPhoto converts an entity.Photo to search.Photo for API responses.
|
||||
func convertEntityToSearchPhoto(photo *entity.Photo) (*search.Photo, error) {
|
||||
searchPhoto := &search.Photo{}
|
||||
|
||||
// Copy common fields automatically
|
||||
deepcopier.Copy(searchPhoto).From(photo)
|
||||
|
||||
// Set required fields manually
|
||||
searchPhoto.CompositeID = fmt.Sprintf("%d", photo.ID)
|
||||
|
||||
// Copy details if they exist
|
||||
if details := photo.GetDetails(); details != nil {
|
||||
searchPhoto.DetailsSubject = details.Subject
|
||||
searchPhoto.DetailsArtist = details.Artist
|
||||
searchPhoto.DetailsCopyright = details.Copyright
|
||||
searchPhoto.DetailsLicense = details.License
|
||||
// mapPhotosByUID converts the provided list into a UID keyed lookup map so repeated
|
||||
// selections can reuse already preloaded entities instead of querying again.
|
||||
func mapPhotosByUID(photos entity.Photos) map[string]*entity.Photo {
|
||||
result := make(map[string]*entity.Photo, len(photos))
|
||||
for _, e := range photos {
|
||||
if e == nil || e.PhotoUID == "" {
|
||||
continue
|
||||
}
|
||||
result[e.PhotoUID] = e
|
||||
}
|
||||
|
||||
return searchPhoto, nil
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,10 @@ var (
|
|||
DefaultOrderMonth = sortby.Oldest
|
||||
)
|
||||
|
||||
var albumMutex = sync.Mutex{}
|
||||
var (
|
||||
albumGlobalLock = sync.Mutex{}
|
||||
albumLocks sync.Map // map[string]*sync.Mutex keyed by UID or normalized title
|
||||
)
|
||||
|
||||
// Albums is a helper slice type for working with groups of albums.
|
||||
type Albums []Album
|
||||
|
|
@ -120,40 +123,78 @@ func AddPhotoToUserAlbums(photoUid string, albums []string, sortOrder, userUid s
|
|||
return fmt.Errorf("album: can not add invalid photo uid %s", clean.Log(photoUid))
|
||||
}
|
||||
|
||||
albumMutex.Lock()
|
||||
defer albumMutex.Unlock()
|
||||
|
||||
for _, album := range albums {
|
||||
var albumUid string
|
||||
|
||||
if album == "" {
|
||||
log.Debugf("album: cannot add photo uid %s because album id was not specified", clean.Log(photoUid))
|
||||
continue
|
||||
}
|
||||
|
||||
if rnd.IsUID(album, AlbumUID) {
|
||||
albumUid = album
|
||||
} else {
|
||||
a := NewUserAlbum(album, AlbumManual, sortOrder, userUid)
|
||||
|
||||
if found := a.Find(); found != nil {
|
||||
albumUid = found.AlbumUID
|
||||
} else if err = a.Create(); err == nil {
|
||||
albumUid = a.AlbumUID
|
||||
} else {
|
||||
log.Errorf("album: %s (add photo %s to albums)", err.Error(), photoUid)
|
||||
}
|
||||
unlock := lockAlbumKey(album)
|
||||
if lockErr := addPhotoToAlbumLocked(photoUid, album, sortOrder, userUid); lockErr != nil {
|
||||
err = lockErr
|
||||
}
|
||||
unlock()
|
||||
}
|
||||
|
||||
if albumUid != "" {
|
||||
entry := PhotoAlbum{AlbumUID: albumUid, PhotoUID: photoUid, Hidden: false}
|
||||
return err
|
||||
}
|
||||
|
||||
if err = entry.Save(); err != nil {
|
||||
log.Errorf("album: %s (add photo %s to albums)", err.Error(), photoUid)
|
||||
}
|
||||
// lockAlbumKey acquires a per-album mutex keyed by UID or normalized title to avoid
|
||||
// serializing unrelated album updates while still preventing duplicate creation when
|
||||
// multiple goroutines target the same album concurrently.
|
||||
func lockAlbumKey(album string) func() {
|
||||
key := strings.TrimSpace(album)
|
||||
|
||||
// Refresh updated timestamp.
|
||||
err = UpdateAlbum(albumUid, Values{"updated_at": TimeStamp()})
|
||||
if key == "" {
|
||||
albumGlobalLock.Lock()
|
||||
return albumGlobalLock.Unlock
|
||||
}
|
||||
|
||||
if rnd.IsUID(key, AlbumUID) {
|
||||
// keep UID as-is so existing albums share the same lock
|
||||
} else {
|
||||
key = strings.ToLower(key)
|
||||
}
|
||||
|
||||
locker, _ := albumLocks.LoadOrStore(key, &sync.Mutex{})
|
||||
mu := locker.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
|
||||
return mu.Unlock
|
||||
}
|
||||
|
||||
// addPhotoToAlbumLocked performs the actual album lookup/creation and relation insert
|
||||
// while assuming the caller already holds the per-album mutex.
|
||||
func addPhotoToAlbumLocked(photoUid, album, sortOrder, userUid string) (err error) {
|
||||
var albumUid string
|
||||
|
||||
if rnd.IsUID(album, AlbumUID) {
|
||||
albumUid = album
|
||||
} else {
|
||||
a := NewUserAlbum(album, AlbumManual, sortOrder, userUid)
|
||||
|
||||
if found := a.Find(); found != nil {
|
||||
albumUid = found.AlbumUID
|
||||
} else if err = a.Create(); err == nil {
|
||||
albumUid = a.AlbumUID
|
||||
} else {
|
||||
log.Errorf("album: %s (add photo %s to albums)", err.Error(), photoUid)
|
||||
}
|
||||
}
|
||||
|
||||
if albumUid == "" {
|
||||
return err
|
||||
}
|
||||
|
||||
entry := PhotoAlbum{AlbumUID: albumUid, PhotoUID: photoUid, Hidden: false}
|
||||
|
||||
if err = entry.Save(); err != nil {
|
||||
log.Errorf("album: %s (add photo %s to albums)", err.Error(), photoUid)
|
||||
}
|
||||
|
||||
if updateErr := UpdateAlbum(albumUid, Values{"updated_at": TimeStamp()}); updateErr != nil {
|
||||
if err == nil {
|
||||
err = updateErr
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package entity
|
|||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -12,6 +13,7 @@ import (
|
|||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
)
|
||||
|
||||
// TestUpdateAlbum exercises the related album behavior.
|
||||
func TestUpdateAlbum(t *testing.T) {
|
||||
t.Run("InvalidUID", func(t *testing.T) {
|
||||
err := UpdateAlbum("xxx", Values{"album_title": "New Title", "album_slug": "new-slug"})
|
||||
|
|
@ -20,6 +22,7 @@ func TestUpdateAlbum(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestAddPhotoToAlbums exercises the related album behavior.
|
||||
func TestAddPhotoToAlbums(t *testing.T) {
|
||||
t.Run("SuccessOneAlbum", func(t *testing.T) {
|
||||
err := AddPhotoToAlbums("ps6sg6bexxvl0yh0", []string{"as6sg6bitoga0004"})
|
||||
|
|
@ -113,6 +116,7 @@ func TestAddPhotoToAlbums(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestAddPhotoToUserAlbums exercises the related album behavior.
|
||||
func TestAddPhotoToUserAlbums(t *testing.T) {
|
||||
t.Run("AddToExistingAlbum", func(t *testing.T) {
|
||||
err := AddPhotoToUserAlbums("ps6sg6bexxvl0yh0", []string{"as6sg6bitoga0004"}, sortby.Oldest, "uqxetse3cy5eo9z2")
|
||||
|
|
@ -162,6 +166,55 @@ func TestAddPhotoToUserAlbums(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestAddPhotoToUserAlbumsConcurrentCreate exercises the related album behavior.
|
||||
func TestAddPhotoToUserAlbumsConcurrentCreate(t *testing.T) {
|
||||
_ = Db().Where("album_title = ?", "ConcurrencyTestAlbum").Unscoped().Delete(&Album{})
|
||||
|
||||
photos := []string{
|
||||
PhotoFixtures.Get("Photo01").PhotoUID,
|
||||
PhotoFixtures.Get("Photo02").PhotoUID,
|
||||
PhotoFixtures.Get("Photo03").PhotoUID,
|
||||
}
|
||||
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan error, len(photos))
|
||||
|
||||
for _, uid := range photos {
|
||||
wg.Add(1)
|
||||
go func(photoUID string) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
results <- AddPhotoToUserAlbums(photoUID, []string{"ConcurrencyTestAlbum"}, sortby.Oldest, OwnerUnknown)
|
||||
}(uid)
|
||||
}
|
||||
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
for err := range results {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var albums []Album
|
||||
if err := Db().Where("album_title = ?", "ConcurrencyTestAlbum").Find(&albums).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(albums) != 1 {
|
||||
t.Fatalf("expected a single album, got %d", len(albums))
|
||||
}
|
||||
|
||||
var relationCount int
|
||||
if err := Db().Table(PhotoAlbum{}.TableName()).Where("album_uid = ?", albums[0].AlbumUID).Count(&relationCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.Equal(t, len(photos), relationCount)
|
||||
}
|
||||
|
||||
// TestNewAlbum exercises the related album behavior.
|
||||
func TestNewAlbum(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
album := NewAlbum("Christmas 2018", AlbumManual)
|
||||
|
|
@ -184,6 +237,7 @@ func TestNewAlbum(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestNewUserAlbum exercises the related album behavior.
|
||||
func TestNewUserAlbum(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
album := NewUserAlbum("Christmas 2024", AlbumManual, "", "uqxqg7i1kperxvu7")
|
||||
|
|
@ -193,6 +247,7 @@ func TestNewUserAlbum(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestNewFolderAlbum exercises the related album behavior.
|
||||
func TestNewFolderAlbum(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
album := NewFolderAlbum("Dogs", "dogs", "label:dog")
|
||||
|
|
@ -208,6 +263,7 @@ func TestNewFolderAlbum(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestNewMomentsAlbum exercises the related album behavior.
|
||||
func TestNewMomentsAlbum(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
album := NewMomentsAlbum("Dogs", "dogs", "label:dog")
|
||||
|
|
@ -223,6 +279,7 @@ func TestNewMomentsAlbum(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestNewStateAlbum exercises the related album behavior.
|
||||
func TestNewStateAlbum(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
album := NewStateAlbum("Dogs", "dogs", "label:dog")
|
||||
|
|
@ -238,6 +295,7 @@ func TestNewStateAlbum(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestNewMonthAlbum exercises the related album behavior.
|
||||
func TestNewMonthAlbum(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
album := NewMonthAlbum("Dogs", "dogs", 2020, 7)
|
||||
|
|
@ -255,6 +313,7 @@ func TestNewMonthAlbum(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestFindMonthAlbum exercises the related album behavior.
|
||||
func TestFindMonthAlbum(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
result := FindMonthAlbum(2021, 9)
|
||||
|
|
@ -277,6 +336,7 @@ func TestFindMonthAlbum(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestFindAlbumBySlug exercises the related album behavior.
|
||||
func TestFindAlbumBySlug(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
result := FindAlbumBySlug("holiday-2030", AlbumManual)
|
||||
|
|
@ -314,6 +374,7 @@ func TestFindAlbumBySlug(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestFindAlbumByAttr exercises the related album behavior.
|
||||
func TestFindAlbumByAttr(t *testing.T) {
|
||||
t.Run("FindByFilter", func(t *testing.T) {
|
||||
result := FindAlbumByAttr([]string{}, []string{"path:\"1990/04\" public:true"}, AlbumFolder)
|
||||
|
|
@ -345,6 +406,7 @@ func TestFindAlbumByAttr(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestFindFolderAlbum exercises the related album behavior.
|
||||
func TestFindFolderAlbum(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
album := FindFolderAlbum("1990/04")
|
||||
|
|
@ -374,6 +436,7 @@ func TestFindFolderAlbum(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestFindAlbum exercises the related album behavior.
|
||||
func TestFindAlbum(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
album := AlbumFixtures.Get("christmas2030")
|
||||
|
|
@ -430,6 +493,7 @@ func TestFindAlbum(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestAlbum_Find exercises the related album behavior.
|
||||
func TestAlbum_Find(t *testing.T) {
|
||||
t.Run("ExistingAlbum", func(t *testing.T) {
|
||||
a := Album{AlbumUID: "as6sg6bitoga0004"}
|
||||
|
|
@ -454,6 +518,7 @@ func TestAlbum_Find(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestAlbum_String exercises the related album behavior.
|
||||
func TestAlbum_String(t *testing.T) {
|
||||
t.Run("ReturnSlug", func(t *testing.T) {
|
||||
album := Album{
|
||||
|
|
@ -493,6 +558,7 @@ func TestAlbum_String(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestAlbum_IsMoment exercises the related album behavior.
|
||||
func TestAlbum_IsMoment(t *testing.T) {
|
||||
t.Run("False", func(t *testing.T) {
|
||||
album := Album{
|
||||
|
|
@ -514,6 +580,7 @@ func TestAlbum_IsMoment(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestAlbum_SetTitle exercises the related album behavior.
|
||||
func TestAlbum_SetTitle(t *testing.T) {
|
||||
t.Run("ValidName", func(t *testing.T) {
|
||||
album := NewAlbum("initial name", AlbumManual)
|
||||
|
|
@ -550,6 +617,7 @@ is an oblate spheroid.`
|
|||
})
|
||||
}
|
||||
|
||||
// TestAlbum_SetLocation exercises the related album behavior.
|
||||
func TestAlbum_SetLocation(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
album := Album{}
|
||||
|
|
@ -577,6 +645,7 @@ func TestAlbum_SetLocation(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestAlbum_UpdateTitleAndLocation exercises the related album behavior.
|
||||
func TestAlbum_UpdateTitleAndLocation(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
album := Album{ID: 12345, AlbumUID: "as6sg6bxpogaakj6"}
|
||||
|
|
@ -655,6 +724,7 @@ func TestAlbum_UpdateTitleAndLocation(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestAlbum_UpdateTitleAndState exercises the related album behavior.
|
||||
func TestAlbum_UpdateTitleAndState(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
album := NewAlbum("Any State", AlbumState)
|
||||
|
|
@ -734,6 +804,7 @@ func TestAlbum_UpdateTitleAndState(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestAlbum_SaveForm exercises the related album behavior.
|
||||
func TestAlbum_SaveForm(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
album := NewAlbum("Old Name", AlbumManual)
|
||||
|
|
@ -763,6 +834,7 @@ func TestAlbum_SaveForm(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestAlbum_Update exercises the related album behavior.
|
||||
func TestAlbum_Update(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
album := NewAlbum("Test Title", AlbumManual)
|
||||
|
|
@ -791,6 +863,7 @@ func TestAlbum_Update(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestAlbum_Updates exercises the related album behavior.
|
||||
func TestAlbum_Updates(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
album := NewAlbum("Test Title", AlbumManual)
|
||||
|
|
@ -819,6 +892,7 @@ func TestAlbum_Updates(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestAlbum_UpdateFolder exercises the related album behavior.
|
||||
func TestAlbum_UpdateFolder(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
a := Album{ID: 99999, AlbumUID: "as6sg6bitogaaxxx"}
|
||||
|
|
@ -863,6 +937,7 @@ func TestAlbum_UpdateFolder(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestAlbum_Save exercises the related album behavior.
|
||||
func TestAlbum_Save(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
album := AlbumFixtures.Get("christmas2030")
|
||||
|
|
@ -880,6 +955,7 @@ func TestAlbum_Save(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestAlbum_Create exercises the related album behavior.
|
||||
func TestAlbum_Create(t *testing.T) {
|
||||
t.Run("Album", func(t *testing.T) {
|
||||
album := Album{
|
||||
|
|
@ -927,6 +1003,7 @@ func TestAlbum_Create(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestAlbum_DeletePermanently exercises the related album behavior.
|
||||
func TestAlbum_DeletePermanently(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
album := NewAlbum("Christmas 2018", AlbumManual)
|
||||
|
|
@ -956,6 +1033,7 @@ func TestAlbum_DeletePermanently(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestAlbum_DeleteRestore exercises the related album behavior.
|
||||
func TestAlbum_DeleteRestore(t *testing.T) {
|
||||
t.Run("DeleteAndRestore", func(t *testing.T) {
|
||||
album := NewAlbum("Test Title", AlbumManual)
|
||||
|
|
@ -1034,6 +1112,7 @@ func TestAlbum_DeleteRestore(t *testing.T) {
|
|||
|
||||
}
|
||||
|
||||
// TestAlbum_Title exercises the related album behavior.
|
||||
func TestAlbum_Title(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
album := Album{
|
||||
|
|
@ -1046,6 +1125,7 @@ func TestAlbum_Title(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestAlbum_ZipName exercises the related album behavior.
|
||||
func TestAlbum_ZipName(t *testing.T) {
|
||||
t.Run("ChristmasNum2030Zip", func(t *testing.T) {
|
||||
album := AlbumFixtures.Get("christmas2030")
|
||||
|
|
@ -1061,6 +1141,7 @@ func TestAlbum_ZipName(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestAlbum_AddPhotos exercises the related album behavior.
|
||||
func TestAlbum_AddPhotos(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
album := Album{
|
||||
|
|
@ -1130,6 +1211,7 @@ func TestAlbum_AddPhotos(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestAlbum_RemovePhotos exercises the related album behavior.
|
||||
func TestAlbum_RemovePhotos(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
album := Album{
|
||||
|
|
@ -1189,6 +1271,7 @@ func TestAlbum_RemovePhotos(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestAlbum_Links exercises the related album behavior.
|
||||
func TestAlbum_Links(t *testing.T) {
|
||||
t.Run("OneResult", func(t *testing.T) {
|
||||
album := AlbumFixtures.Get("christmas2030")
|
||||
|
|
|
|||
|
|
@ -24,3 +24,49 @@ func (m Photos) UIDs() []string {
|
|||
|
||||
return result
|
||||
}
|
||||
|
||||
type PhotoSet struct {
|
||||
order []string
|
||||
items map[string]*Photo
|
||||
idx map[string]int // UID -> index in order
|
||||
}
|
||||
|
||||
func NewPhotoSet() *PhotoSet {
|
||||
return &PhotoSet{
|
||||
order: make([]string, 0),
|
||||
items: make(map[string]*Photo),
|
||||
idx: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PhotoSet) Add(r *Photo) {
|
||||
uid := r.GetUID()
|
||||
|
||||
if _, exists := s.items[uid]; !exists {
|
||||
s.order = append(s.order, uid)
|
||||
s.idx[uid] = len(s.order) - 1
|
||||
}
|
||||
|
||||
s.items[uid] = r
|
||||
}
|
||||
|
||||
func (s *PhotoSet) Delete(uid string) {
|
||||
i, ok := s.idx[uid]
|
||||
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
lastIdx := len(s.order) - 1
|
||||
lastUID := s.order[lastIdx]
|
||||
|
||||
// swap removed element with last
|
||||
s.order[i] = lastUID
|
||||
s.idx[lastUID] = i
|
||||
|
||||
// shrink slice
|
||||
s.order = s.order[:lastIdx]
|
||||
|
||||
delete(s.idx, uid)
|
||||
delete(s.items, uid)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import (
|
|||
|
||||
// PhotoByID returns a Photo based on the ID.
|
||||
func PhotoByID(photoID uint64) (photo entity.Photo, err error) {
|
||||
if err := UnscopedDb().Where("id = ?", photoID).
|
||||
if err = UnscopedDb().Where("id = ?", photoID).
|
||||
Preload("Labels", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Order("photos_labels.uncertainty ASC, photos_labels.label_id DESC")
|
||||
}).
|
||||
|
|
@ -33,7 +33,7 @@ func PhotoByID(photoID uint64) (photo entity.Photo, err error) {
|
|||
|
||||
// PhotoByUID returns a Photo based on the UID.
|
||||
func PhotoByUID(photoUID string) (photo entity.Photo, err error) {
|
||||
if err := UnscopedDb().Where("photo_uid = ?", photoUID).
|
||||
if err = UnscopedDb().Where("photo_uid = ?", photoUID).
|
||||
Preload("Labels", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Order("photos_labels.uncertainty ASC, photos_labels.label_id DESC")
|
||||
}).
|
||||
|
|
@ -53,7 +53,45 @@ func PhotoByUID(photoUID string) (photo entity.Photo, err error) {
|
|||
|
||||
// PhotoPreloadByUID returns a Photo based on the UID with all dependencies preloaded.
|
||||
func PhotoPreloadByUID(photoUID string) (photo entity.Photo, err error) {
|
||||
if err := UnscopedDb().Where("photo_uid = ?", photoUID).
|
||||
if err = preloadPhotoAssociations(UnscopedDb().Where("photo_uid = ?", photoUID)).
|
||||
First(&photo).Error; err != nil {
|
||||
return photo, err
|
||||
}
|
||||
|
||||
photo.PreloadMany()
|
||||
|
||||
return photo, nil
|
||||
}
|
||||
|
||||
// PhotoPreloadByUIDs returns photos for the provided UIDs with supporting associations preloaded.
|
||||
// The call de-duplicates the UID list so callers can forward selection arrays directly without
|
||||
// incurring redundant queries.
|
||||
func PhotoPreloadByUIDs(photoUIDs []string) (entity.Photos, error) {
|
||||
uids := uniqueUIDs(photoUIDs)
|
||||
photos := entity.Photos{}
|
||||
|
||||
if len(uids) == 0 {
|
||||
return photos, nil
|
||||
}
|
||||
|
||||
if err := preloadPhotoAssociations(UnscopedDb().Where("photo_uid IN (?)", uids)).
|
||||
Find(&photos).Error; err != nil {
|
||||
return photos, err
|
||||
}
|
||||
|
||||
for _, photo := range photos {
|
||||
if photo == nil {
|
||||
continue
|
||||
}
|
||||
photo.PreloadMany()
|
||||
}
|
||||
|
||||
return photos, nil
|
||||
}
|
||||
|
||||
// preloadPhotoAssociations applies the eager-load scope that keeps PhotoPreload helpers consistent.
|
||||
func preloadPhotoAssociations(db *gorm.DB) *gorm.DB {
|
||||
return db.
|
||||
Preload("Labels", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Order("photos_labels.uncertainty ASC, photos_labels.label_id DESC")
|
||||
}).
|
||||
|
|
@ -63,14 +101,30 @@ func PhotoPreloadByUID(photoUID string) (photo entity.Photo, err error) {
|
|||
Preload("Details").
|
||||
Preload("Place").
|
||||
Preload("Cell").
|
||||
Preload("Cell.Place").
|
||||
First(&photo).Error; err != nil {
|
||||
return photo, err
|
||||
Preload("Cell.Place")
|
||||
}
|
||||
|
||||
// uniqueUIDs normalizes and de-duplicates selection lists so callers can reuse them as-is.
|
||||
func uniqueUIDs(uids []string) []string {
|
||||
if len(uids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
photo.PreloadMany()
|
||||
result := make([]string, 0, len(uids))
|
||||
seen := make(map[string]struct{}, len(uids))
|
||||
|
||||
return photo, nil
|
||||
for _, uid := range uids {
|
||||
if uid == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[uid]; ok {
|
||||
continue
|
||||
}
|
||||
seen[uid] = struct{}{}
|
||||
result = append(result, uid)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// MissingPhotos returns photo entities without existing files.
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
// TestPhotoByID validates photo query behavior.
|
||||
func TestPhotoByID(t *testing.T) {
|
||||
t.Run("PhotoFound", func(t *testing.T) {
|
||||
result, err := PhotoByID(1000000)
|
||||
|
|
@ -25,6 +26,7 @@ func TestPhotoByID(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestPhotoByUID validates photo query behavior.
|
||||
func TestPhotoByUID(t *testing.T) {
|
||||
t.Run("PhotoFound", func(t *testing.T) {
|
||||
result, err := PhotoByUID("ps6sg6be2lvl0y12")
|
||||
|
|
@ -40,6 +42,7 @@ func TestPhotoByUID(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestPreloadPhotoByUID validates photo query behavior.
|
||||
func TestPreloadPhotoByUID(t *testing.T) {
|
||||
t.Run("PhotoFound", func(t *testing.T) {
|
||||
result, err := PhotoPreloadByUID("ps6sg6be2lvl0y12")
|
||||
|
|
@ -55,6 +58,51 @@ func TestPreloadPhotoByUID(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestPhotoPreloadByUIDs validates photo query behavior.
|
||||
func TestPhotoPreloadByUIDs(t *testing.T) {
|
||||
t.Run("Multiple", func(t *testing.T) {
|
||||
uids := []string{"ps6sg6be2lvl0y12", "ps6sg6be2lvl0y25", "ps6sg6be2lvl0y12"}
|
||||
photos, err := PhotoPreloadByUIDs(uids)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(photos) != 2 {
|
||||
t.Fatalf("expected two unique photos, got %d", len(photos))
|
||||
}
|
||||
|
||||
photoMap := make(map[string]*entity.Photo, len(photos))
|
||||
for _, p := range photos {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
photoMap[p.PhotoUID] = p
|
||||
}
|
||||
|
||||
first := photoMap["ps6sg6be2lvl0y12"]
|
||||
if first == nil {
|
||||
t.Fatalf("expected photo ps6sg6be2lvl0y12 to be preloaded")
|
||||
}
|
||||
assert.Greater(t, len(first.Files), 0)
|
||||
assert.True(t, first.CameraID > 0)
|
||||
|
||||
second := photoMap["ps6sg6be2lvl0y25"]
|
||||
if second == nil {
|
||||
t.Fatalf("expected photo ps6sg6be2lvl0y25 to be preloaded")
|
||||
}
|
||||
assert.Greater(t, len(second.Labels), 0)
|
||||
})
|
||||
|
||||
t.Run("Empty", func(t *testing.T) {
|
||||
photos, err := PhotoPreloadByUIDs(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.Equal(t, 0, len(photos))
|
||||
})
|
||||
}
|
||||
|
||||
// TestMissingPhotos validates photo query behavior.
|
||||
func TestMissingPhotos(t *testing.T) {
|
||||
result, err := MissingPhotos(15, 0)
|
||||
|
||||
|
|
@ -65,6 +113,7 @@ func TestMissingPhotos(t *testing.T) {
|
|||
assert.LessOrEqual(t, 1, len(result))
|
||||
}
|
||||
|
||||
// TestArchivedPhotos validates photo query behavior.
|
||||
func TestArchivedPhotos(t *testing.T) {
|
||||
results, err := ArchivedPhotos(15, 0)
|
||||
|
||||
|
|
@ -81,6 +130,7 @@ func TestArchivedPhotos(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestPhotosMetadataUpdate validates photo query behavior.
|
||||
func TestPhotosMetadataUpdate(t *testing.T) {
|
||||
interval := entity.MetadataUpdateInterval
|
||||
result, err := PhotosMetadataUpdate(10, 0, time.Second, interval)
|
||||
|
|
@ -92,6 +142,7 @@ func TestPhotosMetadataUpdate(t *testing.T) {
|
|||
assert.IsType(t, entity.Photos{}, result)
|
||||
}
|
||||
|
||||
// TestOrphanPhotos validates photo query behavior.
|
||||
func TestOrphanPhotos(t *testing.T) {
|
||||
result, err := OrphanPhotos()
|
||||
|
||||
|
|
@ -103,6 +154,7 @@ func TestOrphanPhotos(t *testing.T) {
|
|||
}
|
||||
|
||||
// TODO How to verify?
|
||||
// TestFixPrimaries validates photo query behavior.
|
||||
func TestFixPrimaries(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
err := FixPrimaries()
|
||||
|
|
@ -112,6 +164,7 @@ func TestFixPrimaries(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestFlagHiddenPhotos validates photo query behavior.
|
||||
func TestFlagHiddenPhotos(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
// Set photo quality scores to -1 if files are missing.
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ func TestMain(m *testing.M) {
|
|||
log.SetLevel(logrus.TraceLevel)
|
||||
event.AuditLog = log
|
||||
|
||||
// Remove temporary SQLite files before running the tests.
|
||||
fs.PurgeTestDbFiles(".", false)
|
||||
|
||||
c := config.TestConfig()
|
||||
defer c.CloseDb()
|
||||
|
||||
|
|
|
|||
200
internal/photoprism/batch/README.md
Normal file
200
internal/photoprism/batch/README.md
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
## PhotoPrism — Batch Edit Package
|
||||
|
||||
**Last Updated:** November 17, 2025
|
||||
|
||||
### Overview
|
||||
|
||||
Batch editing allows signed-in users to update the metadata, albums, and labels of multiple photos without having to load and edit each one individually.
|
||||
|
||||
The `internal/photoprism/batch` package implements the form schema (`PhotosForm`), validation helpers, album/label mutation helpers, and persistence functions (`SavePhotos`) that power the `/api/v1/batch/photos/edit` endpoint. It exists so the API can keep responses consistent with the UI: mixed values stay round-trippable, add/remove operations track intent, and only changed columns hit the database.
|
||||
|
||||
#### Context & Constraints
|
||||
|
||||
- Community requests such as [Issue #271](https://github.com/photoprism/photoprism/issues/271) emphasized the need to bulk-edit core metadata (location, time zone, titles) instead of repeating the same change photo by photo.
|
||||
- [PR #5324](https://github.com/photoprism/photoprism/pull/5324) introduced the modern batch dialog, chip controls, and validation rules that this package still serves.
|
||||
- Batch edits run inside regular API workers; there is no dedicated job queue. We therefore optimize for O(n) database work across selected photos, avoid global locks, and offload heavy recomputation to existing workers (meta, labels, search index).
|
||||
- Frontend components expect round-trip metadata even for fields that are not yet editable (ISO, focal length, copyright, etc.), so the form structs intentionally contain more data than the dialog renders.
|
||||
|
||||
#### Goals
|
||||
|
||||
- Provide a single schema (`PhotosForm`) for reading and writing mixed selections.
|
||||
- Guarantee that album/label updates obey ACLs and deduplicate creations, even when multiple requests fire concurrently.
|
||||
- Minimize writes by only persisting changed columns and deferring derived work (labels, keyword re-indexing) to background workers.
|
||||
- Return refreshed photo models so the UI can immediately render the persisted state without extra queries.
|
||||
|
||||
#### Non-Goals
|
||||
|
||||
- Defining frontend validation or presentation; Vue components own layout, tooltips, and translations.
|
||||
- Replacing specialized batch routes (`batch/photos/delete`, `…/archive`, etc.). This package focuses on metadata edits only.
|
||||
- Managing worker scheduling. We simply mark `checked_at = NULL` so the metadata worker decides when to reprocess.
|
||||
|
||||
### Architecture & Request Flows
|
||||
|
||||
1. The router registers `BatchPhotosEdit` (`internal/api/batch_photos_edit.go`) under `/api/v1/batch/photos/edit`.
|
||||
2. The handler authenticates via `Auth(..., acl.ResourcePhotos, acl.ActionUpdate)` before accepting JSON payloads shaped like `batch.PhotosRequest { photos: [], values: {} }`.
|
||||
3. The handler always reuses the ordered `search.BatchPhotos` results when serializing the `models` array so every response mirrors the original selection and exposes the full `search.Photo` schema (thumbnail hashes, files, etc.) required by the lightbox.
|
||||
4. After persisting updates, the handler issues a follow-up `query.PhotoPreloadByUIDs` call so `batch.PrepareAndSavePhotos` gets hydrated entities for album/label mutations without disrupting the frontend-facing payload.
|
||||
5. `batch.PrepareAndSavePhotos` iterates over the preloaded entities, applies requested album/label changes, builds `PhotoSaveRequest` instances via `batch.NewPhotoSaveRequest`, and persists the updates before returning a summary (requests, results, updated count) to the API layer.
|
||||
6. `SavePhotos` (invoked by the helper) loops once per request, updates only the columns that changed, clears `checked_at`, touches `edited_at`, and queues `entity.UpdateCountsAsync()` once if any photo saved.
|
||||
7. Refreshed entities are converted back to `search.PhotoResults`, and `NewPhotosFormWithEntities` rebuilds the response form so the frontend sees the committed mixed-state snapshot.
|
||||
|
||||
### Batch Edit API Endpoint
|
||||
|
||||
`POST /api/v1/batch/photos/edit` accepts a `PhotosRequest` payload and returns the refreshed photo models plus a `PhotosForm` snapshot. All fields follow JSON casing from `internal/photoprism/batch/request.go` and `photos.go`.
|
||||
|
||||
| Field | Type | Examples | Notes |
|
||||
|-------------------------|------------------|--------------------------------------------------------------|-----------------------------------------------------------------------------------------------|
|
||||
| `photos` | `string[]` | `['pq1z9t3', 'px4y2k0']` | Required. Contains `PhotoUID` values selected in the UI. Empty lists are rejected with `400`. |
|
||||
| `values` | `PhotosForm` | `{ "Title": { "action": "update", "value": "Vacation" } }` | Optional. When omitted, the endpoint only loads the selection + aggregated form data. |
|
||||
| `values.Albums.items[]` | `Items` entry | `{ "value": "ab1c", "title": "Favorites", "action": "add" }` | Action must be `add`/`remove`; `title` is used to create albums when `value` is empty. |
|
||||
| `values.Labels.items[]` | `Items` entry | `{ "value": "lb2d", "action": "remove" }` | Removing requires a valid label UID. Adds accept either UID or plain title. |
|
||||
| `values.TimeZone` | `String` wrapper | `{ "value": "Europe/Berlin", "action": "update" }` | Paired with `Day/Month/Year` to recompute `TakenAt` and `TakenAtLocal`. |
|
||||
| `values.DetailsSubject` | `String` wrapper | `{ "action": "remove" }` | Setting `action="remove"` clears the field without needing `value`. |
|
||||
|
||||
#### Request Example
|
||||
|
||||
```json
|
||||
{
|
||||
"photos": ["pt1abcd", "pt2efgh"],
|
||||
"values": {
|
||||
"Title": { "value": "Sunset Cruise", "action": "update" },
|
||||
"Caption": { "action": "remove" },
|
||||
"TimeZone": { "value": "America/Los_Angeles", "action": "update" },
|
||||
"Day": { "value": 14, "action": "update" },
|
||||
"Month": { "value": 11, "action": "update" },
|
||||
"Year": { "value": 2025, "action": "update" },
|
||||
"Albums": {
|
||||
"action": "update",
|
||||
"items": [
|
||||
{ "title": "Trips 2025", "action": "add" },
|
||||
{ "value": "abcf1234", "action": "remove" }
|
||||
]
|
||||
},
|
||||
"Labels": {
|
||||
"action": "update",
|
||||
"items": [
|
||||
{ "value": "lbtravel", "action": "add" },
|
||||
{ "value": "lbbeta", "action": "remove" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Response Example
|
||||
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"UID": "pt1abcd",
|
||||
"Title": "Sunset Cruise",
|
||||
"Favorite": true,
|
||||
"Albums": [
|
||||
{ "UID": "trips25", "Title": "Trips 2025" }
|
||||
],
|
||||
"Labels": [
|
||||
{ "UID": "lbtravel", "Name": "Travel" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"values": {
|
||||
"Title": { "value": "Sunset Cruise", "mixed": false, "action": "update" },
|
||||
"Caption": { "value": "", "mixed": false, "action": "remove" },
|
||||
"Albums": {
|
||||
"action": "update",
|
||||
"mixed": false,
|
||||
"items": [
|
||||
{ "value": "trips25", "title": "Trips 2025", "mixed": false, "action": "none" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Frontend Integration
|
||||
|
||||
The SPA consumes the endpoint through a dedicated REST model, dialog component, and Vitest suites.
|
||||
|
||||
- **REST Model**: `frontend/src/model/batch.js` exports the `Batch` class, which encapsulates the `/batch/photos/edit` POST calls, manages hydrated `Photo` instances, tracks the current selection, and ensures mixed-value defaults match the backend schema.
|
||||
- **Vue Components**: `frontend/src/component/photo/batch-edit.vue` renders the dialog, binding the model’s `values` to chips, combo boxes, and toggles. It is mounted via `frontend/src/component/dialogs.vue`, which exposes `<p-photo-batch-edit>` so any view can trigger batch edits.
|
||||
- **Vitest Coverage**: `frontend/tests/vitest/model/batch.test.js` mocks Axios to verify that the model posts the correct payloads, updates cached photos, and handles no-op responses. `frontend/tests/vitest/component/photo/batch-edit.test.js` renders the dialog with Vue Test Utils to confirm field bindings, validation flows, and selection toggling behavior.
|
||||
|
||||
### Gating & Configuration
|
||||
|
||||
- ACL: only sessions allowed to `update` the `photos` resource may call this endpoint. That includes administrators and contributors with write access; read-only tokens fail early.
|
||||
- Selection limits: `search.BatchPhotos` caps the request using `form.SearchPhotos.MaxResults` (default 5,000) to prevent runaway updates, and the ordered list returned there is reused verbatim for the API response so we avoid desynchronizing the frontend selection.
|
||||
- Workers: clearing `CheckedAt` ensures the metadata worker (`internal/workers/meta`) and downstream indexers revisit the files within the configured worker interval (default 10–20 minutes).
|
||||
- Environment flags: standard safety toggles (`PHOTOPRISM_READONLY`, maintenance mode, etc.) still apply because the handler runs in the main API process.
|
||||
|
||||
### Supported Fields & Values
|
||||
|
||||
PhotosForm currently carries:
|
||||
|
||||
- Core descriptive metadata: title, caption, type, favorite/private flags, scan/panorama toggles.
|
||||
- Temporal data: exact timestamps, local offsets, broken-out year/month/day, and time zone identifiers.
|
||||
- Location fields: latitude, longitude, altitude, ISO country, derived place/cell IDs (updated via `UpdateLocation`).
|
||||
- Equipment identifiers: camera and lens IDs/serials for future UI expansion.
|
||||
- Details block: subject, artist, copyright, license, keywords (ensuring context from Issue #271 remains editable once the UI exposes the rows).
|
||||
- Albums & labels as `Items` lists, with `Mixed` markers and per-item actions.
|
||||
|
||||
Each field embeds one of the typed wrappers (`String`, `Bool`, `Time`, `Int`, etc.) so the UI knows whether a value is mixed, unchanged, updated, or removed.
|
||||
|
||||
### Overriding Values with Sources & Priorities
|
||||
|
||||
- `Action` enums (`none`, `update`, `add`, `remove`) describe intent. Strings treat `remove` the same as `update` plus empty values, allowing the backend to wipe titles/captions clean.
|
||||
- Source columns (`TitleSrc`, `CaptionSrc`, `TypeSrc`, `PlaceSrc`, details `*_src`) keep track of provenance. `SavePhotos` updates them whenever batch edits win over prior metadata (EXIF, AI, manual, etc.).
|
||||
- Album & label updates respect UID validation: `ApplyAlbums` verifies `PhotoUID` / `AlbumUID`, creates albums by title when needed, and delegates to `entity.AddPhotoToAlbums`, which now uses per-album keyed locks to avoid blocking unrelated requests.
|
||||
- Label writes reuse existing `PhotoLabel` rows when possible, force 100 % confidence for manual/batch additions, and demote AI suggestions by setting `uncertainty = 100` when users explicitly remove them.
|
||||
- Keyword keywords stay consistent because label removals call `photo.RemoveKeyword` and `SaveDetails` immediately, while location edits append unique place keywords via `txt.UniqueWords`.
|
||||
|
||||
### Performance & Concurrency
|
||||
|
||||
- `SavePhotos` only writes dirty columns and updates `photo_details` rows separately, reducing contention and avoiding `entity.SavePhotoForm`’s per-photo cache busts. The API keeps reusing the ordered `search.BatchPhotos` result for serialization, accepting the extra `query.PhotoPreloadByUIDs` call (post-save) until the lightbox can consume entity-only responses or leverage the ordered list helper in `pkg/list/ordered` for a unified flow.
|
||||
- Batch responses reuse the same hydrated entities for both persistence and response rendering, so even selections with hundreds of photos issue a constant number of queries.
|
||||
- Album mutations leverage `entity.lockAlbumKey()` (per-album mutex) so two batches editing disjoint albums proceed in parallel instead of waiting on the global lock used before PR #5324’s follow-up work.
|
||||
- Label operations operate on preloaded associations (`indexPhotoLabels`) to avoid hitting the join table repeatedly.
|
||||
- Background costs (keyword indexing, metadata regeneration) are deferred: clearing `CheckedAt` lets workers refresh derived data asynchronously, and `entity.UpdateCountsAsync()` runs once per batch regardless of size.
|
||||
|
||||
### Known Issues & Limitations
|
||||
|
||||
- UI coverage still lags the schema: EXIF controls such as ISO/f-number remain hidden, so users cannot yet set them even though backend transport exists (tracked in Issue #271).
|
||||
- The endpoint assumes all selected photos are still readable; deleted originals during a batch run lead to warnings and skipped saves rather than hard failures.
|
||||
- Keyword synchronization after label removal is best-effort; if `SaveDetails()` fails, the UI might display stale keywords until the next background refresh.
|
||||
|
||||
### Observability & Testing
|
||||
|
||||
- **Unit Tests**
|
||||
- `internal/photoprism/batch/actions_test.go` validates album/label mutations, UID validation, and keyword handling.
|
||||
- `internal/photoprism/batch/convert_test.go` and `photos_test.go` cover form aggregation and mixed-value detection.
|
||||
- `internal/photoprism/batch/datelogic_test.go` ensures cross-field dependencies (local time vs. UTC) stay consistent.
|
||||
- `internal/photoprism/batch/save_test.go` exercises partial updates, detail edits, `CheckedAt` resets, and the `PreparePhotoSaveRequests` / `PrepareAndSavePhotos` helpers.
|
||||
- `internal/api/batch_photos_edit_test.go` provides end-to-end coverage for response envelopes (`SuccessNoChange`, `SuccessRemoveValues`, etc.).
|
||||
- **Logging**
|
||||
- The package uses the shared `event.Log` logger. Debug logs trace selections, album/label changes, and dirty-field sets; warnings/errors surface failed queries so operators can inspect database health.
|
||||
- **Metrics & Alerts**
|
||||
- The API shares the `/api/v1/metrics` Prometheus endpoint; batch edits increment the standard HTTP counters/latencies. Consider dashboarding 5xx/4xx spikes for `/batch/photos/edit` if you rely heavily on automation.
|
||||
|
||||
### Documentation & References
|
||||
|
||||
- <https://docs.photoprism.app/developer-guide/api/> — API Endpoints & Authentication
|
||||
- <https://docs.photoprism.dev/> — Swagger REST API Documentation
|
||||
- [GitHub Issue #271: Add batch edit dialog to change the metadata of multiple pictures](https://github.com/photoprism/photoprism/issues/271)
|
||||
- [GitHub PR #5324: Implements #271 by adding a batch edit dialog and API endpoint](https://github.com/photoprism/photoprism/pull/5324)
|
||||
|
||||
### Code Map
|
||||
|
||||
- `batch.go` — package doc + logger bindings.
|
||||
- `request.go` / `response.go` — transport structs for the API payload/response.
|
||||
- `photos.go` — form aggregation from `search.PhotoResults` and bulk selection helpers.
|
||||
- `convert.go` — translates `PhotosForm` into `form.Photo` instances for persistence.
|
||||
- `actions.go` — album/label mutation helpers shared across API endpoints.
|
||||
- `save.go` — differential persistence, `PreparePhotoSaveRequests`, `PrepareAndSavePhotos`, `NewPhotoSaveRequest`, `PhotoSaveRequest`, background worker triggers.
|
||||
- `datelogic.go` — helpers for reconciling time zones and date parts when the UI only supplies partial values.
|
||||
- `values.go` — typed wrappers for request fields (value + action + mixed flag).
|
||||
|
||||
### Next Steps
|
||||
|
||||
- [ ] Surface the dormant EXIF controls (ISO, focal length, lens/camera IDs) in the frontend and wire them to `PhotosForm` once the UI/UX is ready.
|
||||
- [ ] Evaluate batching `ApplyLabels`/`ApplyAlbums` at the SQL level for very large selections while keeping validation safeguards.
|
||||
- [ ] Document worker SLA guarantees (metadata refresh latency, label indexing) once observability data is available.
|
||||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
// Action enumerates the supported batch operations such as add/remove/update.
|
||||
type Action = string
|
||||
|
||||
const (
|
||||
|
|
@ -92,6 +93,7 @@ func ApplyLabels(photo *entity.Photo, labels Items) error {
|
|||
|
||||
// Track if we changed anything to call SaveLabels once
|
||||
changed := false
|
||||
labelIndex := indexPhotoLabels(photo.Labels)
|
||||
|
||||
for _, it := range labels.Items {
|
||||
switch it.Action {
|
||||
|
|
@ -104,6 +106,7 @@ func ApplyLabels(photo *entity.Photo, labels Items) error {
|
|||
// Try by UID first
|
||||
var labelEntity *entity.Label
|
||||
var err error
|
||||
|
||||
if it.Value != "" {
|
||||
// If value is provided, validate it's a proper UID format
|
||||
if !rnd.IsUID(it.Value, entity.LabelUID) {
|
||||
|
|
@ -115,6 +118,7 @@ func ApplyLabels(photo *entity.Photo, labels Items) error {
|
|||
return fmt.Errorf("label not found: %s", it.Value)
|
||||
}
|
||||
}
|
||||
|
||||
if labelEntity == nil && it.Title != "" {
|
||||
// Create or find by title
|
||||
labelEntity = entity.FirstOrCreateLabel(entity.NewLabel(it.Title, 0))
|
||||
|
|
@ -128,22 +132,28 @@ func ApplyLabels(photo *entity.Photo, labels Items) error {
|
|||
log.Debugf("batch: could not restore label %s: %s", labelEntity.LabelName, err)
|
||||
}
|
||||
|
||||
pl := labelIndex[labelEntity.ID]
|
||||
|
||||
if pl == nil {
|
||||
pl = entity.FirstOrCreatePhotoLabel(entity.NewPhotoLabel(photo.ID, labelEntity.ID, 0, entity.SrcBatch))
|
||||
if pl == nil {
|
||||
log.Errorf("batch: failed creating photo-label for photo %d and label %d", photo.ID, labelEntity.ID)
|
||||
break
|
||||
}
|
||||
labelIndex[labelEntity.ID] = pl
|
||||
}
|
||||
|
||||
// Ensure 100% confidence (uncertainty 0) and source 'batch'
|
||||
if pl := entity.FirstOrCreatePhotoLabel(entity.NewPhotoLabel(photo.ID, labelEntity.ID, 0, entity.SrcBatch)); pl == nil {
|
||||
log.Errorf("batch: failed creating photo-label for photo %d and label %d", photo.ID, labelEntity.ID)
|
||||
} else {
|
||||
// If it already existed with different values, update it
|
||||
if pl.Uncertainty != 0 || pl.LabelSrc != entity.SrcBatch {
|
||||
pl.Uncertainty = 0
|
||||
pl.LabelSrc = entity.SrcBatch
|
||||
if err := entity.Db().Save(pl).Error; err != nil {
|
||||
log.Errorf("batch: update label to 100%% confidence failed: %s", err)
|
||||
} else {
|
||||
changed = true
|
||||
}
|
||||
if pl.Uncertainty != 0 || pl.LabelSrc != entity.SrcBatch {
|
||||
pl.Uncertainty = 0
|
||||
pl.LabelSrc = entity.SrcBatch
|
||||
if err = entity.Db().Save(pl).Error; err != nil {
|
||||
log.Errorf("batch: update label to 100%% confidence failed: %s", err)
|
||||
} else {
|
||||
changed = true
|
||||
}
|
||||
} else {
|
||||
changed = true
|
||||
}
|
||||
|
||||
case ActionRemove:
|
||||
|
|
@ -157,19 +167,29 @@ func ApplyLabels(photo *entity.Photo, labels Items) error {
|
|||
}
|
||||
|
||||
labelEntity, err := query.LabelByUID(it.Value)
|
||||
|
||||
if err != nil || labelEntity == nil || !labelEntity.HasID() {
|
||||
return fmt.Errorf("label not found for removal: %s", it.Value)
|
||||
}
|
||||
|
||||
if pl, err := query.PhotoLabel(photo.ID, labelEntity.ID); err != nil {
|
||||
log.Debugf("batch: photo-label not found for removal: photo=%s label_id=%d", photo.PhotoUID, labelEntity.ID)
|
||||
continue
|
||||
} else if pl != nil {
|
||||
pl := labelIndex[labelEntity.ID]
|
||||
|
||||
if pl == nil {
|
||||
if cached, queryErr := query.PhotoLabel(photo.ID, labelEntity.ID); queryErr != nil {
|
||||
log.Debugf("batch: photo-label not found for removal: photo=%s label_id=%d", photo.PhotoUID, labelEntity.ID)
|
||||
continue
|
||||
} else {
|
||||
pl = cached
|
||||
}
|
||||
}
|
||||
|
||||
if pl != nil {
|
||||
if (pl.LabelSrc == entity.SrcManual || pl.LabelSrc == entity.SrcBatch) && pl.Uncertainty < 100 {
|
||||
if err := entity.Db().Delete(&pl).Error; err != nil {
|
||||
log.Errorf("batch: delete label failed: %s", err)
|
||||
} else {
|
||||
log.Debugf("batch: deleted label: photo=%s label_id=%d", photo.PhotoUID, labelEntity.ID)
|
||||
delete(labelIndex, labelEntity.ID)
|
||||
changed = true
|
||||
}
|
||||
} else if pl.LabelSrc != entity.SrcManual && pl.LabelSrc != entity.SrcBatch {
|
||||
|
|
@ -186,9 +206,11 @@ func ApplyLabels(photo *entity.Photo, labels Items) error {
|
|||
log.Errorf("batch: save label failed: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
_ = photo.RemoveKeyword(labelEntity.LabelName)
|
||||
|
||||
// Persist updated keywords immediately so the change survives reloads
|
||||
if err := photo.SaveDetails(); err != nil {
|
||||
if err = photo.SaveDetails(); err != nil {
|
||||
log.Debugf("batch: failed to save details after keyword removal: %s", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -203,11 +225,11 @@ func ApplyLabels(photo *entity.Photo, labels Items) error {
|
|||
if changed {
|
||||
// Reload photo to ensure in-memory labels reflect DB changes before saving derived fields
|
||||
if reloaded, err := query.PhotoPreloadByUID(photo.PhotoUID); err == nil && reloaded.HasID() {
|
||||
if err := (&reloaded).SaveLabels(); err != nil {
|
||||
if err = (&reloaded).SaveLabels(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := photo.SaveLabels(); err != nil {
|
||||
if err = photo.SaveLabels(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
@ -215,3 +237,23 @@ func ApplyLabels(photo *entity.Photo, labels Items) error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
// indexPhotoLabels builds a PhotoLabel lookup map so ApplyLabels can reuse the associations that
|
||||
// were already preloaded for the batch selection without re-querying the join table.
|
||||
func indexPhotoLabels(labels entity.PhotoLabels) map[uint]*entity.PhotoLabel {
|
||||
if len(labels) == 0 {
|
||||
return map[uint]*entity.PhotoLabel{}
|
||||
}
|
||||
|
||||
idx := make(map[uint]*entity.PhotoLabel, len(labels))
|
||||
|
||||
for i := range labels {
|
||||
lbl := &labels[i]
|
||||
if lbl == nil || lbl.LabelID == 0 {
|
||||
continue
|
||||
}
|
||||
idx[lbl.LabelID] = lbl
|
||||
}
|
||||
|
||||
return idx
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,13 @@
|
|||
package batch
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/entity/query"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
log = logrus.StandardLogger()
|
||||
log.SetLevel(logrus.TraceLevel)
|
||||
|
||||
db := entity.InitTestDb(
|
||||
os.Getenv("PHOTOPRISM_TEST_DRIVER"),
|
||||
os.Getenv("PHOTOPRISM_TEST_DSN"))
|
||||
|
||||
defer db.Close()
|
||||
|
||||
code := m.Run()
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// TestApplyAlbums exercises batch action logic.
|
||||
func TestApplyAlbums(t *testing.T) {
|
||||
t.Run("AddPhotoToExistingAlbumByUID", func(t *testing.T) {
|
||||
photo := entity.PhotoFixtures.Get("Photo01")
|
||||
|
|
@ -224,13 +208,13 @@ func TestApplyAlbums(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
err := ApplyAlbums(invalidPhotoUID, albums)
|
||||
if err == nil {
|
||||
if err := ApplyAlbums(invalidPhotoUID, albums); err == nil {
|
||||
t.Error("expected error for invalid photo UID, but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestApplyLabels exercises batch action logic.
|
||||
func TestApplyLabels(t *testing.T) {
|
||||
t.Run("AddExistingLabelByUID", func(t *testing.T) {
|
||||
photo := entity.PhotoFixtures.Pointer("Photo06")
|
||||
|
|
@ -248,6 +232,7 @@ func TestApplyLabels(t *testing.T) {
|
|||
|
||||
// Verify photo has the label with 100% confidence and batch source
|
||||
var photoLabel entity.PhotoLabel
|
||||
|
||||
if err := entity.Db().Preload("Label").Where("photo_id = ? AND label_id = (SELECT id FROM labels WHERE label_uid = ?)", photo.ID, labelUID).First(&photoLabel).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
@ -276,6 +261,7 @@ func TestApplyLabels(t *testing.T) {
|
|||
|
||||
// Verify label was created and added to photo
|
||||
var label entity.Label
|
||||
|
||||
if err := entity.Db().Where("label_name = ?", labelTitle).First(&label).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
@ -285,6 +271,7 @@ func TestApplyLabels(t *testing.T) {
|
|||
}
|
||||
|
||||
var photoLabel entity.PhotoLabel
|
||||
|
||||
if err := entity.Db().Where("photo_id = ? AND label_id = ?", photo.ID, label.ID).First(&photoLabel).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
@ -303,12 +290,14 @@ func TestApplyLabels(t *testing.T) {
|
|||
|
||||
// First add the label manually
|
||||
photoLabel := entity.NewPhotoLabel(photo.ID, label.ID, 0, entity.SrcBatch)
|
||||
|
||||
if err := entity.Db().Create(&photoLabel).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify label is on photo
|
||||
var checkPhotoLabel entity.PhotoLabel
|
||||
|
||||
if err := entity.Db().Where("photo_id = ? AND label_id = ?", photo.ID, label.ID).First(&checkPhotoLabel).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
@ -326,17 +315,52 @@ func TestApplyLabels(t *testing.T) {
|
|||
|
||||
// Verify label was removed (should be deleted from photos_labels)
|
||||
var deletedLabel entity.PhotoLabel
|
||||
err := entity.Db().Where("photo_id = ? AND label_id = ?", photo.ID, label.ID).First(&deletedLabel).Error
|
||||
if err == nil {
|
||||
|
||||
if err := entity.Db().Where("photo_id = ? AND label_id = ?", photo.ID, label.ID).First(&deletedLabel).Error; err == nil {
|
||||
t.Error("expected label to be deleted, but it was found")
|
||||
}
|
||||
})
|
||||
t.Run("RemoveLabelWithPreloadedPhoto", func(t *testing.T) {
|
||||
photo := entity.PhotoFixtures.Pointer("Photo17")
|
||||
label := entity.LabelFixtures.Get("landscape")
|
||||
|
||||
entity.Db().Where("photo_id = ? AND label_id = ?", photo.ID, label.ID).Delete(&entity.PhotoLabel{})
|
||||
|
||||
if err := entity.Db().Create(entity.NewPhotoLabel(photo.ID, label.ID, 0, entity.SrcManual)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
preloaded, err := query.PhotoPreloadByUID(photo.PhotoUID)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(preloaded.Labels) == 0 {
|
||||
t.Fatalf("expected preloaded labels for %s", photo.PhotoUID)
|
||||
}
|
||||
|
||||
labels := Items{
|
||||
Items: []Item{{Action: ActionRemove, Value: label.LabelUID}},
|
||||
}
|
||||
|
||||
if err = ApplyLabels(&preloaded, labels); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var deleted entity.PhotoLabel
|
||||
|
||||
if err = entity.Db().Where("photo_id = ? AND label_id = ?", photo.ID, label.ID).First(&deleted).Error; err == nil {
|
||||
t.Fatal("expected label relation to be removed")
|
||||
}
|
||||
})
|
||||
t.Run("RemoveAutoLabelSetsUncertaintyTo100", func(t *testing.T) {
|
||||
photo := entity.PhotoFixtures.Pointer("Photo09")
|
||||
label := entity.LabelFixtures.Get("cake")
|
||||
|
||||
// Add label with auto source (not manual/batch)
|
||||
photoLabel := entity.NewPhotoLabel(photo.ID, label.ID, 15, entity.SrcImage)
|
||||
|
||||
if err := entity.Db().Create(&photoLabel).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
@ -353,6 +377,7 @@ func TestApplyLabels(t *testing.T) {
|
|||
|
||||
// Verify label uncertainty was set to 100% (blocked)
|
||||
var blockedLabel entity.PhotoLabel
|
||||
|
||||
if err := entity.Db().Where("photo_id = ? AND label_id = ?", photo.ID, label.ID).First(&blockedLabel).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
@ -374,6 +399,7 @@ func TestApplyLabels(t *testing.T) {
|
|||
|
||||
// Add label with some uncertainty using FirstOrCreatePhotoLabel
|
||||
photoLabel := entity.FirstOrCreatePhotoLabel(entity.NewPhotoLabel(photo.ID, label.ID, 50, entity.SrcImage))
|
||||
|
||||
if photoLabel == nil {
|
||||
t.Fatal("failed to create photo label")
|
||||
}
|
||||
|
|
@ -400,6 +426,7 @@ func TestApplyLabels(t *testing.T) {
|
|||
|
||||
// Verify label confidence was updated
|
||||
var updatedLabel entity.PhotoLabel
|
||||
|
||||
if err := entity.Db().Where("photo_id = ? AND label_id = ?", photo.ID, label.ID).First(&updatedLabel).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
@ -420,12 +447,14 @@ func TestApplyLabels(t *testing.T) {
|
|||
}
|
||||
|
||||
err := ApplyLabels(nil, labels)
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected error for nil photo")
|
||||
}
|
||||
|
||||
emptyPhoto := &entity.Photo{}
|
||||
err = ApplyLabels(emptyPhoto, labels)
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected error for empty photo")
|
||||
}
|
||||
|
|
@ -442,6 +471,7 @@ func TestApplyLabels(t *testing.T) {
|
|||
}
|
||||
|
||||
err := ApplyLabels(photo, labels)
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected error when adding non-existing label by UID, but got none")
|
||||
}
|
||||
|
|
@ -457,6 +487,7 @@ func TestApplyLabels(t *testing.T) {
|
|||
}
|
||||
|
||||
err := ApplyLabels(photo, labels)
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected error when adding label with invalid UID, but got none")
|
||||
}
|
||||
|
|
@ -500,6 +531,7 @@ func TestApplyLabels(t *testing.T) {
|
|||
|
||||
// This should not error, but should be a no-op
|
||||
err := ApplyLabels(photo, labels)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for empty label items, but got: %v", err)
|
||||
}
|
||||
|
|
@ -514,6 +546,7 @@ func TestApplyLabels(t *testing.T) {
|
|||
}
|
||||
|
||||
err := ApplyLabels(photo, labels)
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected error when both Value and Title are empty, but got none")
|
||||
}
|
||||
|
|
@ -532,8 +565,28 @@ func TestApplyLabels(t *testing.T) {
|
|||
}
|
||||
|
||||
err := ApplyLabels(photo, labels)
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected error when removing label not assigned to photo, but got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestIndexPhotoLabels exercises batch action logic.
|
||||
func TestIndexPhotoLabels(t *testing.T) {
|
||||
labels := entity.PhotoLabels{
|
||||
{LabelID: 11},
|
||||
{LabelID: 0},
|
||||
{LabelID: 22},
|
||||
}
|
||||
|
||||
idx := indexPhotoLabels(labels)
|
||||
|
||||
if len(idx) != 2 {
|
||||
t.Fatalf("expected 2 labels in index, got %d", len(idx))
|
||||
}
|
||||
|
||||
if idx[11] == nil || idx[22] == nil {
|
||||
t.Fatal("expected indexed labels to be present")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
/*
|
||||
Package batch provides batch editing forms, types and validation.
|
||||
Package batch coordinates PhotoPrism’s multi-photo edit workflow by defining
|
||||
the PhotosForm schema, helper types for expressing add/remove actions, and the
|
||||
validation / persistence helpers (ApplyAlbums, ApplyLabels, SavePhotos, etc.)
|
||||
that the API layer uses to safely mutate selections of photos at once.
|
||||
|
||||
Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved.
|
||||
|
||||
|
|
|
|||
37
internal/photoprism/batch/batch_test.go
Normal file
37
internal/photoprism/batch/batch_test.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
package batch
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
"github.com/photoprism/photoprism/internal/photoprism"
|
||||
"github.com/photoprism/photoprism/internal/photoprism/get"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
// TestMain configures shared state for the batch action tests.
|
||||
func TestMain(m *testing.M) {
|
||||
log = logrus.StandardLogger()
|
||||
log.SetLevel(logrus.TraceLevel)
|
||||
event.AuditLog = log
|
||||
|
||||
// Remove temporary SQLite files before running the tests.
|
||||
fs.PurgeTestDbFiles(".", false)
|
||||
|
||||
c := config.TestConfig()
|
||||
defer c.CloseDb()
|
||||
|
||||
get.SetConfig(c)
|
||||
photoprism.SetConfig(c)
|
||||
|
||||
code := m.Run()
|
||||
|
||||
// Remove temporary SQLite files after running the tests.
|
||||
fs.PurgeTestDbFiles(".", false)
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package batch
|
|||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/entity/query"
|
||||
"github.com/photoprism/photoprism/internal/entity/search"
|
||||
)
|
||||
|
|
@ -47,9 +48,17 @@ type PhotosForm struct {
|
|||
DetailsLicense String `json:"DetailsLicense,omitempty"`
|
||||
}
|
||||
|
||||
// NewPhotosForm returns a new batch edit form instance
|
||||
// initialized with values from the selected photos.
|
||||
// NewPhotosForm returns a new batch edit form instance initialized with values from the
|
||||
// selected photos. It falls back to reloading each photo individually.
|
||||
func NewPhotosForm(photos search.PhotoResults) *PhotosForm {
|
||||
return NewPhotosFormWithEntities(photos, nil)
|
||||
}
|
||||
|
||||
// NewPhotosFormWithEntities builds the batch edit form using the supplied photo selection and
|
||||
// an optional map of preloaded entity.Photo instances that should be reused instead of issuing
|
||||
// additional queries. When the map is nil or a photo is missing, the helper falls back to
|
||||
// `query.PhotoPreloadByUID` so legacy call sites keep working.
|
||||
func NewPhotosFormWithEntities(photos search.PhotoResults, preloaded map[string]*entity.Photo) *PhotosForm {
|
||||
// Create a new batch edit form and initialize it
|
||||
// with the values from the selected photos.
|
||||
frm := &PhotosForm{
|
||||
|
|
@ -57,6 +66,24 @@ func NewPhotosForm(photos search.PhotoResults) *PhotosForm {
|
|||
Labels: Items{Items: []Item{}, Mixed: false, Action: ActionNone},
|
||||
}
|
||||
|
||||
getPhoto := func(uid string) *entity.Photo {
|
||||
if uid == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if preloaded != nil {
|
||||
if p := preloaded[uid]; p != nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
if p, err := query.PhotoPreloadByUID(uid); err == nil && p.HasID() {
|
||||
return &p
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Populate Albums and Labels from selected photos (no raw SQL; use preload helpers)
|
||||
total := len(photos)
|
||||
if total > 0 {
|
||||
|
|
@ -64,10 +91,12 @@ func NewPhotosForm(photos search.PhotoResults) *PhotosForm {
|
|||
title string
|
||||
cnt int
|
||||
}
|
||||
|
||||
type labelAgg struct {
|
||||
name string
|
||||
cnt int
|
||||
}
|
||||
|
||||
albumCount := map[string]albumAgg{}
|
||||
labelCount := map[string]labelAgg{}
|
||||
|
||||
|
|
@ -75,10 +104,11 @@ func NewPhotosForm(photos search.PhotoResults) *PhotosForm {
|
|||
if sp.PhotoUID == "" {
|
||||
continue
|
||||
}
|
||||
p, err := query.PhotoPreloadByUID(sp.PhotoUID)
|
||||
if err != nil || !p.HasID() {
|
||||
p := getPhoto(sp.PhotoUID)
|
||||
if p == nil || !p.HasID() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Albums on this photo
|
||||
for _, a := range p.Albums {
|
||||
if a.AlbumUID == "" || a.Deleted() {
|
||||
|
|
@ -89,6 +119,7 @@ func NewPhotosForm(photos search.PhotoResults) *PhotosForm {
|
|||
v.cnt++
|
||||
albumCount[a.AlbumUID] = v
|
||||
}
|
||||
|
||||
// Labels on this photo (only visible ones: uncertainty < 100)
|
||||
for _, pl := range p.Labels {
|
||||
if pl.Uncertainty >= 100 || pl.Label == nil || !pl.Label.HasID() {
|
||||
|
|
@ -108,6 +139,7 @@ func NewPhotosForm(photos search.PhotoResults) *PhotosForm {
|
|||
// Build Albums items
|
||||
frm.Albums.Items = make([]Item, 0, len(albumCount))
|
||||
anyAlbumMixed := false
|
||||
|
||||
for uid, agg := range albumCount {
|
||||
mixed := agg.cnt > 0 && agg.cnt < total
|
||||
if mixed {
|
||||
|
|
@ -115,6 +147,7 @@ func NewPhotosForm(photos search.PhotoResults) *PhotosForm {
|
|||
}
|
||||
frm.Albums.Items = append(frm.Albums.Items, Item{Value: uid, Title: agg.title, Mixed: mixed, Action: ActionNone})
|
||||
}
|
||||
|
||||
// Sort shared-first (Mixed=false), then by Title alphabetically
|
||||
sort.Slice(frm.Albums.Items, func(i, j int) bool {
|
||||
if frm.Albums.Items[i].Mixed != frm.Albums.Items[j].Mixed {
|
||||
|
|
@ -122,6 +155,7 @@ func NewPhotosForm(photos search.PhotoResults) *PhotosForm {
|
|||
}
|
||||
return frm.Albums.Items[i].Title < frm.Albums.Items[j].Title
|
||||
})
|
||||
|
||||
frm.Albums.Mixed = anyAlbumMixed
|
||||
frm.Albums.Action = ActionNone
|
||||
|
||||
|
|
@ -135,6 +169,7 @@ func NewPhotosForm(photos search.PhotoResults) *PhotosForm {
|
|||
}
|
||||
frm.Labels.Items = append(frm.Labels.Items, Item{Value: uid, Title: agg.name, Mixed: mixed, Action: ActionNone})
|
||||
}
|
||||
|
||||
// Sort shared-first (Mixed=false), then by Title alphabetically
|
||||
sort.Slice(frm.Labels.Items, func(i, j int) bool {
|
||||
if frm.Labels.Items[i].Mixed != frm.Labels.Items[j].Mixed {
|
||||
|
|
@ -142,6 +177,7 @@ func NewPhotosForm(photos search.PhotoResults) *PhotosForm {
|
|||
}
|
||||
return frm.Labels.Items[i].Title < frm.Labels.Items[j].Title
|
||||
})
|
||||
|
||||
frm.Labels.Mixed = anyLabelMixed
|
||||
frm.Labels.Action = ActionNone
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,15 @@ import (
|
|||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
func TestNewPhotosForm_FromJSON(t *testing.T) {
|
||||
func TestNewPhotosForm(t *testing.T) {
|
||||
t.Run("FromJSON", runNewPhotosFormFromJSON)
|
||||
t.Run("FromFixturesAlbumsLabels", runNewPhotosFormFromFixtures)
|
||||
t.Run("TwoPhotosMixedFlags1", runNewPhotosFormMixedFlags1)
|
||||
t.Run("TwoPhotosMixedFlags2", runNewPhotosFormMixedFlags2)
|
||||
}
|
||||
|
||||
// runNewPhotosFormFromJSON exercises PhotosForm behavior.
|
||||
func runNewPhotosFormFromJSON(t *testing.T) {
|
||||
var photos search.PhotoResults
|
||||
|
||||
dataFile := fs.Abs("./testdata/photos.json")
|
||||
|
|
@ -74,7 +82,8 @@ func TestNewPhotosForm_FromJSON(t *testing.T) {
|
|||
assert.Equal(t, true, frm.DetailsLicense.Mixed)
|
||||
}
|
||||
|
||||
func TestNewPhotosForm_FromFixturesAlbumsLabels(t *testing.T) {
|
||||
// runNewPhotosFormFromFixtures ensures preloaded fixtures behave.
|
||||
func runNewPhotosFormFromFixtures(t *testing.T) {
|
||||
|
||||
// Ensure test config and fixtures/DB are initialized.
|
||||
_ = config.TestConfig()
|
||||
|
|
@ -97,7 +106,8 @@ func TestNewPhotosForm_FromFixturesAlbumsLabels(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestNewPhotosForm_TwoPhotosMixedFlags1(t *testing.T) {
|
||||
// runNewPhotosFormMixedFlags1 captures mixed flag handling.
|
||||
func runNewPhotosFormMixedFlags1(t *testing.T) {
|
||||
photo1 := search.Photo{
|
||||
ID: 111115411,
|
||||
PhotoUID: "",
|
||||
|
|
@ -185,7 +195,8 @@ func TestNewPhotosForm_TwoPhotosMixedFlags1(t *testing.T) {
|
|||
assert.Equal(t, true, frm.PhotoYear.Mixed)
|
||||
}
|
||||
|
||||
func TestNewPhotosForm_TwoPhotosMixedFlags2(t *testing.T) {
|
||||
// runNewPhotosFormMixedFlags2 covers camera/lens variance.
|
||||
func runNewPhotosFormMixedFlags2(t *testing.T) {
|
||||
photo1 := search.Photo{
|
||||
ID: 111115411,
|
||||
PhotoUID: "",
|
||||
|
|
@ -256,3 +267,25 @@ func TestNewPhotosForm_TwoPhotosMixedFlags2(t *testing.T) {
|
|||
assert.Equal(t, 2020, frm.PhotoYear.Value)
|
||||
assert.Equal(t, false, frm.PhotoYear.Mixed)
|
||||
}
|
||||
|
||||
func TestNewPhotosFormWithEntities(t *testing.T) {
|
||||
t.Run("FallsBack", runNewPhotosFormWithEntitiesFallsBack)
|
||||
}
|
||||
|
||||
// runNewPhotosFormWithEntitiesFallsBack ensures helper falls back correctly.
|
||||
func runNewPhotosFormWithEntitiesFallsBack(t *testing.T) {
|
||||
_ = config.TestConfig()
|
||||
photos := search.PhotoResults{
|
||||
{PhotoUID: "pqkm36fjqvset9uz"},
|
||||
{PhotoUID: "pqkm36fjqvset9uy"},
|
||||
}
|
||||
|
||||
legacy := NewPhotosForm(photos)
|
||||
withNil := NewPhotosFormWithEntities(photos, nil)
|
||||
|
||||
if assert.NotNil(t, legacy) && assert.NotNil(t, withNil) {
|
||||
assert.Equal(t, legacy.PhotoTitle.Value, withNil.PhotoTitle.Value)
|
||||
assert.Equal(t, len(legacy.Albums.Items), len(withNil.Albums.Items))
|
||||
assert.Equal(t, len(legacy.Labels.Items), len(withNil.Labels.Items))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
386
internal/photoprism/batch/save.go
Normal file
386
internal/photoprism/batch/save.go
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
package batch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/entity/query"
|
||||
"github.com/photoprism/photoprism/internal/entity/search"
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
"github.com/photoprism/photoprism/pkg/txt"
|
||||
)
|
||||
|
||||
// PhotoSaveRequest bundles the context required to persist a batch update for a single photo.
|
||||
type PhotoSaveRequest struct {
|
||||
Photo *entity.Photo
|
||||
Form *form.Photo
|
||||
Values *PhotosForm
|
||||
}
|
||||
|
||||
// SaveBatchResult describes the outcome of PrepareAndSavePhotos so callers can publish events
|
||||
// and build responses without re-walking the selection.
|
||||
type SaveBatchResult struct {
|
||||
Requests []*PhotoSaveRequest
|
||||
Results []bool
|
||||
Preloaded map[string]*entity.Photo
|
||||
UpdatedCount int
|
||||
SavedAny bool
|
||||
}
|
||||
|
||||
// NewPhotoSaveRequest converts the batch values into a form.Photo and bundles it with the
|
||||
// target entity so callers outside this package do not have to depend on ConvertToPhotoForm.
|
||||
func NewPhotoSaveRequest(photo *entity.Photo, values *PhotosForm) (*PhotoSaveRequest, error) {
|
||||
if values == nil {
|
||||
return nil, fmt.Errorf("batch: values are required")
|
||||
}
|
||||
|
||||
frm, err := ConvertToPhotoForm(photo, values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &PhotoSaveRequest{Photo: photo, Form: frm, Values: values}, nil
|
||||
}
|
||||
|
||||
// PreparePhotoSaveRequests converts the given selection into save requests, ensuring each photo
|
||||
// entity is hydrated and album/label actions are applied once per selection. The returned map is
|
||||
// the (possibly newly populated) preloaded set so callers can reuse it for response rendering.
|
||||
func PreparePhotoSaveRequests(photos search.PhotoResults, preloaded map[string]*entity.Photo, values *PhotosForm) ([]*PhotoSaveRequest, map[string]*entity.Photo) {
|
||||
if values == nil {
|
||||
return nil, preloaded
|
||||
}
|
||||
|
||||
if preloaded == nil {
|
||||
preloaded = map[string]*entity.Photo{}
|
||||
}
|
||||
|
||||
log.Debugf("batch: updating photo metadata for %d photos", len(photos))
|
||||
saveRequests := make([]*PhotoSaveRequest, 0, len(photos))
|
||||
|
||||
for _, result := range photos {
|
||||
photoID := result.PhotoUID
|
||||
if photoID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
fullPhoto := preloaded[photoID]
|
||||
if fullPhoto == nil {
|
||||
loaded, err := query.PhotoPreloadByUID(photoID)
|
||||
if err != nil {
|
||||
log.Errorf("batch: failed to load photo %s: %s", photoID, err)
|
||||
continue
|
||||
}
|
||||
fullPhoto = &loaded
|
||||
preloaded[photoID] = fullPhoto
|
||||
}
|
||||
|
||||
saveReq, err := NewPhotoSaveRequest(fullPhoto, values)
|
||||
if err != nil {
|
||||
log.Errorf("batch: failed to build save request for photo %s: %s", photoID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
saveRequests = append(saveRequests, saveReq)
|
||||
|
||||
if values.Albums.Action == ActionUpdate {
|
||||
if err := ApplyAlbums(photoID, values.Albums); err != nil {
|
||||
log.Errorf("batch: failed to update albums for photo %s: %s", photoID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if values.Labels.Action == ActionUpdate {
|
||||
if err := ApplyLabels(fullPhoto, values.Labels); err != nil {
|
||||
log.Errorf("batch: failed to update labels for photo %s: %s", photoID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return saveRequests, preloaded
|
||||
}
|
||||
|
||||
// PrepareAndSavePhotos hydrates the photo selection, applies album/label mutations, builds save
|
||||
// requests, and persists the changes in one step. It returns a SaveBatchResult so callers can run
|
||||
// follow-up work (events, cache flushes) without re-querying state.
|
||||
func PrepareAndSavePhotos(photos search.PhotoResults, preloaded map[string]*entity.Photo, values *PhotosForm) (*SaveBatchResult, error) {
|
||||
result := &SaveBatchResult{Preloaded: preloaded}
|
||||
if values == nil {
|
||||
if result.Preloaded == nil {
|
||||
result.Preloaded = map[string]*entity.Photo{}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
requests, preloaded := PreparePhotoSaveRequests(photos, result.Preloaded, values)
|
||||
result.Requests = requests
|
||||
result.Preloaded = preloaded
|
||||
|
||||
if len(requests) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
saveResults, err := SavePhotos(requests)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result.Results = saveResults
|
||||
for i, saved := range saveResults {
|
||||
if saved {
|
||||
result.UpdatedCount++
|
||||
result.SavedAny = true
|
||||
log.Debugf("batch: successfully updated photo %s", requests[i].Photo.PhotoUID)
|
||||
}
|
||||
}
|
||||
|
||||
log.Infof("batch: successfully updated %d out of %d photos", result.UpdatedCount, len(photos))
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SavePhotos persists the batch updates described by the provided requests while skipping the
|
||||
// heavyweight per-photo maintenance performed by entity.SavePhotoForm. It only updates the
|
||||
// columns that actually changed, flags the photo for background metadata refresh by clearing
|
||||
// CheckedAt, and updates the shared counts once per batch instead of once per photo.
|
||||
func SavePhotos(requests []*PhotoSaveRequest) ([]bool, error) {
|
||||
results := make([]bool, len(requests))
|
||||
anySaved := false
|
||||
|
||||
for i, req := range requests {
|
||||
saved, err := savePhoto(req)
|
||||
if err != nil {
|
||||
return results, err
|
||||
}
|
||||
results[i] = saved
|
||||
if saved {
|
||||
anySaved = true
|
||||
}
|
||||
}
|
||||
|
||||
if anySaved {
|
||||
entity.UpdateCountsAsync()
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// savePhoto applies the batched form values to a single entity.Photo and writes
|
||||
// only the changed columns back to the database.
|
||||
func savePhoto(req *PhotoSaveRequest) (bool, error) {
|
||||
if req == nil || req.Photo == nil || req.Form == nil || req.Values == nil {
|
||||
return false, fmt.Errorf("batch: invalid save request")
|
||||
}
|
||||
|
||||
p := req.Photo
|
||||
formValues := req.Values
|
||||
formData := req.Form
|
||||
|
||||
if !p.HasID() {
|
||||
return false, fmt.Errorf("batch: photo has no id")
|
||||
}
|
||||
|
||||
original := *p
|
||||
details := p.GetDetails()
|
||||
origDetails := *details
|
||||
|
||||
if shouldUpdateString(formValues.PhotoTitle) {
|
||||
p.PhotoTitle = formData.PhotoTitle
|
||||
p.TitleSrc = formData.TitleSrc
|
||||
}
|
||||
|
||||
if shouldUpdateString(formValues.PhotoCaption) {
|
||||
p.PhotoCaption = formData.PhotoCaption
|
||||
p.CaptionSrc = formData.CaptionSrc
|
||||
}
|
||||
|
||||
if formValues.PhotoType.Action == ActionUpdate {
|
||||
p.PhotoType = formData.PhotoType
|
||||
p.TypeSrc = formData.TypeSrc
|
||||
}
|
||||
|
||||
if formValues.PhotoFavorite.Action == ActionUpdate {
|
||||
p.PhotoFavorite = formValues.PhotoFavorite.Value
|
||||
}
|
||||
|
||||
if formValues.PhotoPrivate.Action == ActionUpdate {
|
||||
p.PhotoPrivate = formValues.PhotoPrivate.Value
|
||||
}
|
||||
|
||||
if formValues.PhotoScan.Action == ActionUpdate {
|
||||
p.PhotoScan = formValues.PhotoScan.Value
|
||||
}
|
||||
|
||||
if formValues.PhotoPanorama.Action == ActionUpdate {
|
||||
p.PhotoPanorama = formValues.PhotoPanorama.Value
|
||||
}
|
||||
|
||||
timeChanged := formValues.PhotoDay.Action == ActionUpdate ||
|
||||
formValues.PhotoMonth.Action == ActionUpdate ||
|
||||
formValues.PhotoYear.Action == ActionUpdate ||
|
||||
formValues.TimeZone.Action == ActionUpdate
|
||||
|
||||
if timeChanged {
|
||||
p.PhotoYear = formData.PhotoYear
|
||||
p.PhotoMonth = formData.PhotoMonth
|
||||
p.PhotoDay = formData.PhotoDay
|
||||
if formValues.TimeZone.Action == ActionUpdate {
|
||||
p.TimeZone = formData.TimeZone
|
||||
}
|
||||
p.TakenAtLocal = formData.TakenAtLocal
|
||||
p.TakenSrc = formData.TakenSrc
|
||||
|
||||
p.NormalizeValues()
|
||||
|
||||
if p.TimeZoneUTC() {
|
||||
p.TakenAt = p.TakenAtLocal
|
||||
} else {
|
||||
p.TakenAt = p.GetTakenAt()
|
||||
}
|
||||
|
||||
p.UpdateDateFields()
|
||||
}
|
||||
|
||||
locationChanged := formValues.PhotoLat.Action == ActionUpdate ||
|
||||
formValues.PhotoLng.Action == ActionUpdate ||
|
||||
formValues.PhotoCountry.Action == ActionUpdate ||
|
||||
formValues.PhotoAltitude.Action == ActionUpdate
|
||||
|
||||
if formValues.PhotoLat.Action == ActionUpdate {
|
||||
p.PhotoLat = formValues.PhotoLat.Value
|
||||
}
|
||||
|
||||
if formValues.PhotoLng.Action == ActionUpdate {
|
||||
p.PhotoLng = formValues.PhotoLng.Value
|
||||
}
|
||||
|
||||
if formValues.PhotoAltitude.Action == ActionUpdate {
|
||||
p.PhotoAltitude = formValues.PhotoAltitude.Value
|
||||
}
|
||||
|
||||
if formValues.PhotoCountry.Action == ActionUpdate {
|
||||
p.PhotoCountry = formValues.PhotoCountry.Value
|
||||
}
|
||||
|
||||
if locationChanged {
|
||||
p.PlaceSrc = entity.SrcBatch
|
||||
locKeywords, locLabels := p.UpdateLocation()
|
||||
if len(locLabels) > 0 {
|
||||
p.AddLabels(locLabels)
|
||||
}
|
||||
if len(locKeywords) > 0 {
|
||||
words := txt.UniqueWords(txt.Words(details.Keywords))
|
||||
words = append(words, locKeywords...)
|
||||
details.Keywords = strings.Join(txt.UniqueWords(words), ", ")
|
||||
}
|
||||
}
|
||||
|
||||
if formValues.DetailsSubject.Action == ActionUpdate || formValues.DetailsSubject.Action == ActionRemove {
|
||||
details.Subject = formData.Details.Subject
|
||||
details.SubjectSrc = formData.Details.SubjectSrc
|
||||
}
|
||||
|
||||
if formValues.DetailsArtist.Action == ActionUpdate || formValues.DetailsArtist.Action == ActionRemove {
|
||||
details.Artist = formData.Details.Artist
|
||||
details.ArtistSrc = formData.Details.ArtistSrc
|
||||
}
|
||||
|
||||
if formValues.DetailsCopyright.Action == ActionUpdate || formValues.DetailsCopyright.Action == ActionRemove {
|
||||
details.Copyright = formData.Details.Copyright
|
||||
details.CopyrightSrc = formData.Details.CopyrightSrc
|
||||
}
|
||||
|
||||
if formValues.DetailsLicense.Action == ActionUpdate || formValues.DetailsLicense.Action == ActionRemove {
|
||||
details.License = formData.Details.License
|
||||
details.LicenseSrc = formData.Details.LicenseSrc
|
||||
}
|
||||
|
||||
updates := entity.Values{}
|
||||
addUpdate := func(column string, changed bool, value interface{}) {
|
||||
if changed {
|
||||
updates[column] = value
|
||||
}
|
||||
}
|
||||
|
||||
addUpdate("photo_title", p.PhotoTitle != original.PhotoTitle, p.PhotoTitle)
|
||||
addUpdate("title_src", p.TitleSrc != original.TitleSrc, p.TitleSrc)
|
||||
addUpdate("photo_caption", p.PhotoCaption != original.PhotoCaption, p.PhotoCaption)
|
||||
addUpdate("caption_src", p.CaptionSrc != original.CaptionSrc, p.CaptionSrc)
|
||||
addUpdate("photo_type", p.PhotoType != original.PhotoType, p.PhotoType)
|
||||
addUpdate("type_src", p.TypeSrc != original.TypeSrc, p.TypeSrc)
|
||||
addUpdate("photo_favorite", p.PhotoFavorite != original.PhotoFavorite, p.PhotoFavorite)
|
||||
addUpdate("photo_private", p.PhotoPrivate != original.PhotoPrivate, p.PhotoPrivate)
|
||||
addUpdate("photo_scan", p.PhotoScan != original.PhotoScan, p.PhotoScan)
|
||||
addUpdate("photo_panorama", p.PhotoPanorama != original.PhotoPanorama, p.PhotoPanorama)
|
||||
addUpdate("photo_year", p.PhotoYear != original.PhotoYear, p.PhotoYear)
|
||||
addUpdate("photo_month", p.PhotoMonth != original.PhotoMonth, p.PhotoMonth)
|
||||
addUpdate("photo_day", p.PhotoDay != original.PhotoDay, p.PhotoDay)
|
||||
addUpdate("time_zone", p.TimeZone != original.TimeZone, p.TimeZone)
|
||||
addUpdate("taken_src", p.TakenSrc != original.TakenSrc, p.TakenSrc)
|
||||
addUpdate("taken_at", !p.TakenAt.Equal(original.TakenAt), p.TakenAt)
|
||||
addUpdate("taken_at_local", !p.TakenAtLocal.Equal(original.TakenAtLocal), p.TakenAtLocal)
|
||||
addUpdate("photo_lat", p.PhotoLat != original.PhotoLat, p.PhotoLat)
|
||||
addUpdate("photo_lng", p.PhotoLng != original.PhotoLng, p.PhotoLng)
|
||||
addUpdate("photo_altitude", p.PhotoAltitude != original.PhotoAltitude, p.PhotoAltitude)
|
||||
addUpdate("photo_country", p.PhotoCountry != original.PhotoCountry, p.PhotoCountry)
|
||||
addUpdate("place_id", p.PlaceID != original.PlaceID, p.PlaceID)
|
||||
addUpdate("cell_id", p.CellID != original.CellID, p.CellID)
|
||||
addUpdate("place_src", p.PlaceSrc != original.PlaceSrc, p.PlaceSrc)
|
||||
|
||||
detailUpdates := entity.Values{}
|
||||
if details.Subject != origDetails.Subject {
|
||||
detailUpdates["subject"] = details.Subject
|
||||
}
|
||||
if details.SubjectSrc != origDetails.SubjectSrc {
|
||||
detailUpdates["subject_src"] = details.SubjectSrc
|
||||
}
|
||||
if details.Artist != origDetails.Artist {
|
||||
detailUpdates["artist"] = details.Artist
|
||||
}
|
||||
if details.ArtistSrc != origDetails.ArtistSrc {
|
||||
detailUpdates["artist_src"] = details.ArtistSrc
|
||||
}
|
||||
if details.Copyright != origDetails.Copyright {
|
||||
detailUpdates["copyright"] = details.Copyright
|
||||
}
|
||||
if details.CopyrightSrc != origDetails.CopyrightSrc {
|
||||
detailUpdates["copyright_src"] = details.CopyrightSrc
|
||||
}
|
||||
if details.License != origDetails.License {
|
||||
detailUpdates["license"] = details.License
|
||||
}
|
||||
if details.LicenseSrc != origDetails.LicenseSrc {
|
||||
detailUpdates["license_src"] = details.LicenseSrc
|
||||
}
|
||||
if details.Keywords != origDetails.Keywords {
|
||||
detailUpdates["keywords"] = details.Keywords
|
||||
}
|
||||
|
||||
if len(updates) == 0 && len(detailUpdates) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
log.Debugf("batch: saving photo %s with updates=%v details=%v", p.PhotoUID, updates, detailUpdates)
|
||||
|
||||
edited := entity.Now()
|
||||
updates["edited_at"] = edited
|
||||
p.EditedAt = &edited
|
||||
updates["checked_at"] = nil
|
||||
p.CheckedAt = nil
|
||||
|
||||
if err := entity.Db().Model(p).Updates(updates).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if len(detailUpdates) > 0 {
|
||||
if err := entity.Db().Model(details).Updates(detailUpdates).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// shouldUpdateString reports whether the provided field requests an update or removal.
|
||||
func shouldUpdateString(v String) bool {
|
||||
return v.Action == ActionUpdate || v.Action == ActionRemove
|
||||
}
|
||||
308
internal/photoprism/batch/save_test.go
Normal file
308
internal/photoprism/batch/save_test.go
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
package batch
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/entity/search"
|
||||
)
|
||||
|
||||
// TestSavePhotos covers SavePhotos scenarios.
|
||||
func TestSavePhotos(t *testing.T) {
|
||||
t.Run("UpdatesTitleAndFavorite", func(t *testing.T) {
|
||||
fixture := entity.PhotoFixtures.Get("Photo01")
|
||||
photo := entity.FindPhoto(entity.Photo{PhotoUID: fixture.PhotoUID})
|
||||
require.NotNil(t, photo)
|
||||
|
||||
originalTitle := photo.PhotoTitle
|
||||
originalTitleSrc := photo.TitleSrc
|
||||
originalFavorite := photo.PhotoFavorite
|
||||
originalChecked := photo.CheckedAt
|
||||
originalEdited := photo.EditedAt
|
||||
|
||||
values := &PhotosForm{
|
||||
PhotoTitle: String{Value: originalTitle + " (Batch)", Action: ActionUpdate},
|
||||
PhotoFavorite: Bool{Value: !originalFavorite, Action: ActionUpdate},
|
||||
}
|
||||
|
||||
req, err := NewPhotoSaveRequest(photo, values)
|
||||
require.NoError(t, err)
|
||||
|
||||
results, err := SavePhotos([]*PhotoSaveRequest{req})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, 1)
|
||||
assert.True(t, results[0])
|
||||
|
||||
updated := entity.FindPhoto(entity.Photo{PhotoUID: fixture.PhotoUID})
|
||||
require.NotNil(t, updated)
|
||||
assert.Equal(t, values.PhotoTitle.Value, updated.PhotoTitle)
|
||||
assert.Equal(t, entity.SrcBatch, updated.TitleSrc)
|
||||
assert.Equal(t, values.PhotoFavorite.Value, updated.PhotoFavorite)
|
||||
assert.Nil(t, updated.CheckedAt)
|
||||
assert.NotNil(t, updated.EditedAt)
|
||||
|
||||
restorePhoto(t, fixture.PhotoUID, entity.Values{
|
||||
"photo_title": originalTitle,
|
||||
"title_src": originalTitleSrc,
|
||||
"photo_favorite": originalFavorite,
|
||||
"checked_at": originalChecked,
|
||||
"edited_at": originalEdited,
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("UpdatesDateFields", func(t *testing.T) {
|
||||
fixture := entity.PhotoFixtures.Get("Photo02")
|
||||
photo := entity.FindPhoto(entity.Photo{PhotoUID: fixture.PhotoUID})
|
||||
require.NotNil(t, photo)
|
||||
|
||||
originalYear := photo.PhotoYear
|
||||
originalMonth := photo.PhotoMonth
|
||||
originalDay := photo.PhotoDay
|
||||
originalTakenAt := photo.TakenAt
|
||||
originalTakenAtLocal := photo.TakenAtLocal
|
||||
originalTimeZone := photo.TimeZone
|
||||
originalTakenSrc := photo.TakenSrc
|
||||
originalChecked := photo.CheckedAt
|
||||
originalEdited := photo.EditedAt
|
||||
|
||||
values := &PhotosForm{
|
||||
PhotoYear: Int{Value: 2020, Action: ActionUpdate},
|
||||
PhotoMonth: Int{Value: 5, Action: ActionUpdate},
|
||||
PhotoDay: Int{Value: 15, Action: ActionUpdate},
|
||||
}
|
||||
|
||||
req, err := NewPhotoSaveRequest(photo, values)
|
||||
require.NoError(t, err)
|
||||
|
||||
results, err := SavePhotos([]*PhotoSaveRequest{req})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, 1)
|
||||
assert.True(t, results[0])
|
||||
|
||||
updated := entity.FindPhoto(entity.Photo{PhotoUID: fixture.PhotoUID})
|
||||
require.NotNil(t, updated)
|
||||
assert.Equal(t, 2020, updated.PhotoYear)
|
||||
assert.Equal(t, 5, updated.PhotoMonth)
|
||||
assert.Equal(t, 15, updated.PhotoDay)
|
||||
assert.Equal(t, entity.SrcBatch, updated.TakenSrc)
|
||||
assert.Nil(t, updated.CheckedAt)
|
||||
|
||||
restorePhoto(t, fixture.PhotoUID, entity.Values{
|
||||
"photo_year": originalYear,
|
||||
"photo_month": originalMonth,
|
||||
"photo_day": originalDay,
|
||||
"taken_at": originalTakenAt,
|
||||
"taken_at_local": originalTakenAtLocal,
|
||||
"time_zone": originalTimeZone,
|
||||
"taken_src": originalTakenSrc,
|
||||
"checked_at": originalChecked,
|
||||
"edited_at": originalEdited,
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("RemovesStrings", func(t *testing.T) {
|
||||
fixture := entity.PhotoFixtures.Get("Photo03")
|
||||
photo := entity.FindPhoto(entity.Photo{PhotoUID: fixture.PhotoUID})
|
||||
require.NotNil(t, photo)
|
||||
|
||||
original := entity.Values{
|
||||
"photo_title": photo.PhotoTitle,
|
||||
"title_src": photo.TitleSrc,
|
||||
"photo_caption": photo.PhotoCaption,
|
||||
"caption_src": photo.CaptionSrc,
|
||||
"details_subject": photo.GetDetails().Subject,
|
||||
"details_subject_src": photo.GetDetails().SubjectSrc,
|
||||
"details_artist": photo.GetDetails().Artist,
|
||||
"details_artist_src": photo.GetDetails().ArtistSrc,
|
||||
"details_copyright": photo.GetDetails().Copyright,
|
||||
"details_copyright_src": photo.GetDetails().CopyrightSrc,
|
||||
"details_license": photo.GetDetails().License,
|
||||
"details_license_src": photo.GetDetails().LicenseSrc,
|
||||
"checked_at": photo.CheckedAt,
|
||||
"edited_at": photo.EditedAt,
|
||||
}
|
||||
|
||||
setValues := &PhotosForm{
|
||||
PhotoTitle: String{Value: "Batch Title", Action: ActionUpdate},
|
||||
PhotoCaption: String{Value: "Batch Caption", Action: ActionUpdate},
|
||||
DetailsSubject: String{Value: "Batch Subject", Action: ActionUpdate},
|
||||
DetailsArtist: String{Value: "Batch Artist", Action: ActionUpdate},
|
||||
DetailsCopyright: String{Value: "Batch Copyright", Action: ActionUpdate},
|
||||
DetailsLicense: String{Value: "Batch License", Action: ActionUpdate},
|
||||
}
|
||||
setReq, err := NewPhotoSaveRequest(photo, setValues)
|
||||
require.NoError(t, err)
|
||||
_, err = SavePhotos([]*PhotoSaveRequest{setReq})
|
||||
require.NoError(t, err)
|
||||
|
||||
removeValues := &PhotosForm{
|
||||
PhotoTitle: String{Action: ActionRemove},
|
||||
PhotoCaption: String{Action: ActionRemove},
|
||||
DetailsSubject: String{Action: ActionRemove},
|
||||
DetailsArtist: String{Action: ActionRemove},
|
||||
DetailsCopyright: String{Action: ActionRemove},
|
||||
DetailsLicense: String{Action: ActionRemove},
|
||||
PhotoYear: Int{Value: -1, Action: ActionUpdate},
|
||||
PhotoMonth: Int{Value: -1, Action: ActionUpdate},
|
||||
PhotoDay: Int{Value: -1, Action: ActionUpdate},
|
||||
PhotoAltitude: Int{Value: 0, Action: ActionUpdate},
|
||||
}
|
||||
removeReq, err := NewPhotoSaveRequest(photo, removeValues)
|
||||
require.NoError(t, err)
|
||||
results, err := SavePhotos([]*PhotoSaveRequest{removeReq})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, 1)
|
||||
assert.True(t, results[0])
|
||||
|
||||
updated := entity.FindPhoto(entity.Photo{PhotoUID: fixture.PhotoUID})
|
||||
require.NotNil(t, updated)
|
||||
assert.Equal(t, "", updated.PhotoTitle)
|
||||
assert.Equal(t, "", updated.PhotoCaption)
|
||||
assert.Equal(t, "", updated.GetDetails().Subject)
|
||||
assert.Equal(t, "", updated.GetDetails().Artist)
|
||||
assert.Equal(t, "", updated.GetDetails().Copyright)
|
||||
assert.Equal(t, "", updated.GetDetails().License)
|
||||
|
||||
restorePhoto(t, fixture.PhotoUID, original)
|
||||
})
|
||||
}
|
||||
|
||||
// TestNewPhotoSaveRequest ensures the helper validates inputs before building requests.
|
||||
func TestNewPhotoSaveRequest(t *testing.T) {
|
||||
t.Run("NilValues", func(t *testing.T) {
|
||||
fixture := entity.PhotoFixtures.Get("Photo01")
|
||||
photo := entity.FindPhoto(entity.Photo{PhotoUID: fixture.PhotoUID})
|
||||
require.NotNil(t, photo)
|
||||
|
||||
req, err := NewPhotoSaveRequest(photo, nil)
|
||||
assert.Nil(t, req)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("BuildsRequest", func(t *testing.T) {
|
||||
fixture := entity.PhotoFixtures.Get("Photo02")
|
||||
photo := entity.FindPhoto(entity.Photo{PhotoUID: fixture.PhotoUID})
|
||||
require.NotNil(t, photo)
|
||||
|
||||
values := &PhotosForm{
|
||||
PhotoTitle: String{Value: "Helper", Action: ActionUpdate},
|
||||
}
|
||||
|
||||
req, err := NewPhotoSaveRequest(photo, values)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, req)
|
||||
assert.Equal(t, photo, req.Photo)
|
||||
assert.Equal(t, values, req.Values)
|
||||
assert.NotNil(t, req.Form)
|
||||
assert.Equal(t, "Helper", req.Form.PhotoTitle)
|
||||
})
|
||||
}
|
||||
|
||||
// TestPreparePhotoSaveRequests verifies helper behavior.
|
||||
func TestPreparePhotoSaveRequests(t *testing.T) {
|
||||
t.Run("NilValues", func(t *testing.T) {
|
||||
preloaded := map[string]*entity.Photo{}
|
||||
requests, updated := PreparePhotoSaveRequests(nil, preloaded, nil)
|
||||
assert.Nil(t, requests)
|
||||
assert.Equal(t, preloaded, updated)
|
||||
})
|
||||
|
||||
t.Run("LoadsMissingPhoto", func(t *testing.T) {
|
||||
fixture := entity.PhotoFixtures.Get("Photo01")
|
||||
values := &PhotosForm{PhotoTitle: String{Value: "Prepared", Action: ActionUpdate}}
|
||||
photos := search.PhotoResults{{PhotoUID: fixture.PhotoUID}}
|
||||
|
||||
requests, updated := PreparePhotoSaveRequests(photos, nil, values)
|
||||
require.Len(t, requests, 1)
|
||||
assert.NotNil(t, requests[0].Photo)
|
||||
assert.Equal(t, "Prepared", requests[0].Form.PhotoTitle)
|
||||
assert.Contains(t, updated, fixture.PhotoUID)
|
||||
})
|
||||
|
||||
t.Run("SkipsMissing", func(t *testing.T) {
|
||||
values := &PhotosForm{PhotoTitle: String{Value: "Prepared", Action: ActionUpdate}}
|
||||
photos := search.PhotoResults{{PhotoUID: "pt_does_not_exist"}}
|
||||
|
||||
requests, updated := PreparePhotoSaveRequests(photos, nil, values)
|
||||
assert.Len(t, requests, 0)
|
||||
assert.Empty(t, updated)
|
||||
})
|
||||
}
|
||||
|
||||
// TestPrepareAndSavePhotos verifies the full helper workflow.
|
||||
func TestPrepareAndSavePhotos(t *testing.T) {
|
||||
t.Run("NilValues", func(t *testing.T) {
|
||||
result, err := PrepareAndSavePhotos(nil, nil, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.NotNil(t, result.Preloaded)
|
||||
assert.Len(t, result.Requests, 0)
|
||||
assert.Len(t, result.Results, 0)
|
||||
})
|
||||
|
||||
t.Run("PersistsChanges", func(t *testing.T) {
|
||||
fixture := entity.PhotoFixtures.Get("Photo02")
|
||||
photo := entity.FindPhoto(entity.Photo{PhotoUID: fixture.PhotoUID})
|
||||
require.NotNil(t, photo)
|
||||
|
||||
originalFavorite := photo.PhotoFavorite
|
||||
|
||||
values := &PhotosForm{
|
||||
PhotoFavorite: Bool{Value: !originalFavorite, Action: ActionUpdate},
|
||||
}
|
||||
photos := search.PhotoResults{{PhotoUID: fixture.PhotoUID}}
|
||||
|
||||
result, err := PrepareAndSavePhotos(photos, nil, values)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Len(t, result.Requests, 1)
|
||||
require.Len(t, result.Results, 1)
|
||||
assert.True(t, result.Results[0])
|
||||
assert.True(t, result.SavedAny)
|
||||
assert.Equal(t, 1, result.UpdatedCount)
|
||||
assert.Contains(t, result.Preloaded, fixture.PhotoUID)
|
||||
|
||||
updated := entity.FindPhoto(entity.Photo{PhotoUID: fixture.PhotoUID})
|
||||
require.NotNil(t, updated)
|
||||
assert.Equal(t, !originalFavorite, updated.PhotoFavorite)
|
||||
|
||||
restorePhoto(t, fixture.PhotoUID, entity.Values{
|
||||
"photo_favorite": originalFavorite,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// restorePhoto rewinds DB state for the provided fixture so tests stay isolated.
|
||||
func restorePhoto(t *testing.T, photoUID string, values entity.Values) {
|
||||
t.Helper()
|
||||
if values == nil {
|
||||
return
|
||||
}
|
||||
detailUpdates := entity.Values{}
|
||||
detailKeys := []string{
|
||||
"details_subject", "details_subject_src",
|
||||
"details_artist", "details_artist_src",
|
||||
"details_copyright", "details_copyright_src",
|
||||
"details_license", "details_license_src",
|
||||
}
|
||||
for _, k := range detailKeys {
|
||||
if v, ok := values[k]; ok {
|
||||
detailUpdates[strings.TrimPrefix(k, "details_")] = v
|
||||
delete(values, k)
|
||||
}
|
||||
}
|
||||
if err := entity.Db().Model(&entity.Photo{}).Where("photo_uid = ?", photoUID).Updates(values).Error; err != nil {
|
||||
t.Fatalf("failed to restore photo %s: %v", photoUID, err)
|
||||
}
|
||||
if len(detailUpdates) > 0 {
|
||||
if photo := entity.FindPhoto(entity.Photo{PhotoUID: photoUID}); photo != nil {
|
||||
if err := entity.Db().Model(photo.GetDetails()).Updates(detailUpdates).Error; err != nil {
|
||||
t.Fatalf("failed to restore photo details %s: %v", photoUID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue