mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-11 16:24:11 +01:00
178 lines
4.9 KiB
Go
178 lines
4.9 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
|
|
"github.com/dustin/go-humanize/english"
|
|
"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/event"
|
|
"github.com/photoprism/photoprism/internal/photoprism/batch"
|
|
"github.com/photoprism/photoprism/internal/photoprism/get"
|
|
"github.com/photoprism/photoprism/pkg/clean"
|
|
"github.com/photoprism/photoprism/pkg/i18n"
|
|
"github.com/photoprism/photoprism/pkg/log/status"
|
|
)
|
|
|
|
// BatchPhotosEdit returns and updates the metadata of multiple photos.
|
|
//
|
|
// @Summary returns and updates the metadata of multiple photos
|
|
// @Id BatchPhotosEdit
|
|
// @Tags Photos
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Success 200 {object} batch.PhotosResponse
|
|
// @Failure 400,401,403,404,429,500 {object} i18n.Response
|
|
// @Param Request body batch.PhotosRequest true "photos selection and values"
|
|
// @Router /api/v1/batch/photos/edit [post]
|
|
func BatchPhotosEdit(router *gin.RouterGroup) {
|
|
router.Match(MethodsPutPost, "/batch/photos/edit", func(c *gin.Context) {
|
|
// 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
|
|
}
|
|
|
|
// Check feature flags.
|
|
if !get.Config().Settings().Features.BatchEdit {
|
|
AbortFeatureDisabled(c)
|
|
return
|
|
}
|
|
|
|
var frm batch.PhotosRequest
|
|
|
|
// Assign and validate request form values.
|
|
if err := c.BindJSON(&frm); err != nil {
|
|
AbortBadRequest(c, err)
|
|
return
|
|
}
|
|
|
|
if len(frm.Photos) == 0 {
|
|
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
|
|
return
|
|
}
|
|
|
|
// Fetch selected photos from database.
|
|
photos, count, err := search.BatchPhotos(frm.Photos, s)
|
|
|
|
log.Debugf("batch: %s selected for editing", english.Plural(count, "photo", "photos"))
|
|
|
|
// Abort if no photos were found.
|
|
if err != nil {
|
|
log.Errorf("batch: %s (load selection)", clean.Error(err))
|
|
AbortUnexpectedError(c)
|
|
return
|
|
}
|
|
|
|
var preloadedPhotos map[string]*entity.Photo
|
|
|
|
if hydrated, preloadErr := query.PhotoPreloadByUIDs(photos.UIDs()); preloadErr != nil {
|
|
log.Errorf("batch: %s (preload selection)", preloadErr)
|
|
AbortUnexpectedError(c)
|
|
return
|
|
} else {
|
|
preloadedPhotos = mapPhotosByUID(hydrated)
|
|
}
|
|
|
|
var (
|
|
saveRequests []*batch.PhotoSaveRequest
|
|
saveResults []bool
|
|
savedAny bool
|
|
)
|
|
|
|
if frm.Values != nil {
|
|
outcome, saveErr := batch.PrepareAndSavePhotos(photos, preloadedPhotos, frm.Values)
|
|
|
|
switch {
|
|
case errors.Is(saveErr, batch.ErrBatchEditBusy), errors.Is(saveErr, batch.ErrBatchEditCanceled):
|
|
log.Warnf("batch: %s (save)", saveErr)
|
|
AbortBusy(c)
|
|
return
|
|
case saveErr != nil:
|
|
log.Errorf("batch: %s (save)", saveErr)
|
|
event.AuditErr([]string{ClientIP(c), "session %s", "batch edit", status.Error(saveErr)}, s.RefID)
|
|
AbortUnexpectedError(c)
|
|
return
|
|
}
|
|
|
|
saveRequests = outcome.Requests
|
|
saveResults = outcome.Results
|
|
preloadedPhotos = outcome.Preloaded
|
|
savedAny = outcome.SavedAny
|
|
|
|
if n := len(saveRequests); n > 0 && (savedAny || outcome.Stats.AlbumMutations > 0 || outcome.Stats.LabelMutations > 0) {
|
|
event.AuditInfo([]string{ClientIP(c), "session %s", "batch edit", "update %s", status.Succeeded},
|
|
s.RefID, english.Plural(n, "picture", "pictures"))
|
|
}
|
|
}
|
|
|
|
// Refresh selection if core metadata changed; albums and labels are automatically refreshed.
|
|
if savedAny {
|
|
if photos, _, 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{
|
|
Models: photos,
|
|
Values: batchFrm,
|
|
}
|
|
|
|
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
|
|
}
|