UX: Add batch edit dialog and API endpoints #271 #5324

Signed-off-by: Michael Mayer <michael@photoprism.app>
Co-authored-by: Michael Mayer <michael@photoprism.app>
Co-authored-by: graciousgrey <theresagresch@gmail.com>
This commit is contained in:
Ömer Duran
2025-11-19 11:20:34 +01:00
committed by GitHub
parent 18806935fd
commit 1e00d1f52e
173 changed files with 13034 additions and 1090 deletions

View File

@@ -7,9 +7,10 @@ import (
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/auth/acl"
"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/batch"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/internal/photoprism/batch"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/i18n"
)
@@ -27,16 +28,16 @@ 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
}
conf := get.Config()
if !conf.Develop() && !conf.Experimental() {
AbortNotImplemented(c)
// Require update permissions for photos.
if acl.Rules.Deny(acl.ResourcePhotos, s.GetUserRole(), acl.ActionUpdate) {
AbortForbidden(c)
return
}
@@ -60,21 +61,74 @@ 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
}
// TODO: Implement photo metadata update based on submitted form values.
if frm.Values != nil {
log.Debugf("batch: updating photo metadata %#v (not yet implemented)", frm.Values)
for _, photo := range photos {
log.Debugf("batch: updating metadata of photo %s (not yet implemented)", photo.PhotoUID)
}
preloadedPhotos := map[string]*entity.Photo{}
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 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, preloadedPhotos)
if len(saveResults) > 0 {
for i, saved := range saveResults {
if !saved {
continue
}
photo := preloadedPhotos[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{
@@ -85,3 +139,18 @@ func BatchPhotosEdit(router *gin.RouterGroup) {
c.JSON(http.StatusOK, data)
})
}
// 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 result
}