API: Rename /batch/photos endpoint to /batch/photos/edit #271

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-05-04 14:09:23 +02:00
parent b423b1980b
commit dd6e17e97e
27 changed files with 1937 additions and 1083 deletions

View File

@@ -144,6 +144,12 @@ export class Photo extends RestModel {
Hash: "", Hash: "",
Width: "", Width: "",
Height: "", Height: "",
// Details.
DetailsKeywords: "",
DetailsSubject: "",
DetailsArtist: "",
DetailsCopyright: "",
DetailsLicense: "",
// Date fields. // Date fields.
CreatedAt: "", CreatedAt: "",
UpdatedAt: "", UpdatedAt: "",

View File

@@ -39,7 +39,6 @@ func TestMain(m *testing.M) {
event.AuditLog = log event.AuditLog = log
// Init test config. // Init test config.
config.Develop = true
c := config.TestConfig() c := config.TestConfig()
get.SetConfig(c) get.SetConfig(c)
defer c.CloseDb() defer c.CloseDb()

View File

@@ -1,519 +0,0 @@
package api
import (
"net/http"
"path"
"time"
"github.com/dustin/go-humanize/english"
"github.com/gin-gonic/gin"
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/internal/auth/acl"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/entity/query"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/i18n"
)
// BatchPhotosArchive moves multiple pictures to the archive.
//
// @Summary moves multiple pictures to the archive
// @Id BatchPhotosArchive
// @Tags Photos
// @Accept json
// @Produce json
// @Success 200 {object} i18n.Response
// @Failure 400,401,403,404,429,500 {object} i18n.Response
// @Param photos body form.Selection true "Photo Selection"
// @Router /api/v1/batch/photos/archive [post]
func BatchPhotosArchive(router *gin.RouterGroup) {
router.POST("/batch/photos/archive", func(c *gin.Context) {
s := Auth(c, acl.ResourcePhotos, acl.ActionDelete)
if s.Abort(c) {
return
}
var frm form.Selection
// Assign and validate request form values.
if err := c.BindJSON(&frm); err != nil {
AbortBadRequest(c)
return
}
if len(frm.Photos) == 0 {
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
return
}
log.Infof("photos: archiving %s", clean.Log(frm.String()))
if get.Config().SidecarYaml() {
// Fetch selection from index.
photos, err := query.SelectedPhotos(frm)
if err != nil {
AbortEntityNotFound(c)
return
}
for _, p := range photos {
if archiveErr := p.Archive(); archiveErr != nil {
log.Errorf("archive: %s", archiveErr)
} else {
SaveSidecarYaml(p)
}
}
} else if err := entity.Db().Where("photo_uid IN (?)", frm.Photos).Delete(&entity.Photo{}).Error; err != nil {
log.Errorf("archive: failed to archive %d pictures (%s)", len(frm.Photos), err)
AbortSaveFailed(c)
return
} else if err = entity.Db().Model(&entity.PhotoAlbum{}).Where("photo_uid IN (?)", frm.Photos).UpdateColumn("hidden", true).Error; err != nil {
log.Errorf("archive: failed to flag %d pictures as hidden (%s)", len(frm.Photos), err)
}
// Update precalculated photo and file counts.
entity.UpdateCountsAsync()
// Update album, subject, and label cover thumbs.
query.UpdateCoversAsync()
UpdateClientConfig()
event.EntitiesArchived("photos", frm.Photos)
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgSelectionArchived))
})
}
// BatchPhotosRestore restores multiple pictures from the archive.
//
// @Summary restores multiple pictures from the archive
// @Id BatchPhotosRestore
// @Tags Photos
// @Accept json
// @Produce json
// @Success 200 {object} i18n.Response
// @Failure 400,401,403,404,429,500 {object} i18n.Response
// @Param photos body form.Selection true "Photo Selection"
// @Router /api/v1/batch/photos/restore [post]
func BatchPhotosRestore(router *gin.RouterGroup) {
router.POST("/batch/photos/restore", func(c *gin.Context) {
s := Auth(c, acl.ResourcePhotos, acl.ActionDelete)
if s.Abort(c) {
return
}
var frm form.Selection
if err := c.BindJSON(&frm); err != nil {
AbortBadRequest(c)
return
}
if len(frm.Photos) == 0 {
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
return
}
log.Infof("photos: restoring %s", clean.Log(frm.String()))
if get.Config().SidecarYaml() {
// Fetch selection from index.
photos, err := query.SelectedPhotos(frm)
if err != nil {
AbortEntityNotFound(c)
return
}
for _, p := range photos {
if err = p.Restore(); err != nil {
log.Errorf("restore: %s", err)
} else {
SaveSidecarYaml(p)
}
}
} else if err := entity.Db().Unscoped().Model(&entity.Photo{}).Where("photo_uid IN (?)", frm.Photos).
UpdateColumn("deleted_at", gorm.Expr("NULL")).Error; err != nil {
log.Errorf("restore: %s", err)
AbortSaveFailed(c)
return
}
// Update precalculated photo and file counts.
entity.UpdateCountsAsync()
// Update album, subject, and label cover thumbs.
query.UpdateCoversAsync()
UpdateClientConfig()
event.EntitiesRestored("photos", frm.Photos)
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgSelectionRestored))
})
}
// BatchPhotosApprove approves multiple pictures that are currently under review.
//
// @Summary approves multiple pictures that are currently under review
// @Id BatchPhotosApprove
// @Tags Photos
// @Accept json
// @Produce json
// @Success 200 {object} i18n.Response
// @Failure 400,401,403,404,429 {object} i18n.Response
// @Param photos body form.Selection true "Photo Selection"
// @Router /api/v1/batch/photos/approve [post]
func BatchPhotosApprove(router *gin.RouterGroup) {
router.POST("batch/photos/approve", func(c *gin.Context) {
s := Auth(c, acl.ResourcePhotos, acl.ActionUpdate)
if s.Abort(c) {
return
}
var frm form.Selection
if err := c.BindJSON(&frm); err != nil {
AbortBadRequest(c)
return
}
if len(frm.Photos) == 0 {
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
return
}
log.Infof("photos: approving %s", clean.Log(frm.String()))
// Fetch selection from index.
photos, err := query.SelectedPhotos(frm)
if err != nil {
AbortEntityNotFound(c)
return
}
var approved entity.Photos
for _, p := range photos {
if err = p.Approve(); err != nil {
log.Errorf("approve: %s", err)
} else {
approved = append(approved, p)
SaveSidecarYaml(p)
}
}
UpdateClientConfig()
event.EntitiesUpdated("photos", approved)
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgSelectionApproved))
})
}
// BatchAlbumsDelete permanently removes multiple albums.
//
// @Summary permanently removes multiple albums
// @Id BatchAlbumsDelete
// @Tags Albums
// @Accept json
// @Produce json
// @Success 200 {object} i18n.Response
// @Failure 400,401,403,404,429 {object} i18n.Response
// @Param albums body form.Selection true "Album Selection"
// @Router /api/v1/batch/albums/delete [post]
func BatchAlbumsDelete(router *gin.RouterGroup) {
router.POST("/batch/albums/delete", func(c *gin.Context) {
s := Auth(c, acl.ResourceAlbums, acl.ActionDelete)
if s.Abort(c) {
return
}
var frm form.Selection
if err := c.BindJSON(&frm); err != nil {
AbortBadRequest(c)
return
}
// Get album UIDs.
albumUIDs := frm.Albums
if len(albumUIDs) == 0 {
Abort(c, http.StatusBadRequest, i18n.ErrNoAlbumsSelected)
return
}
log.Infof("albums: deleting %s", clean.Log(frm.String()))
// Fetch albums.
albums, queryErr := query.AlbumsByUID(albumUIDs, false)
if queryErr != nil {
log.Errorf("albums: %s (find)", queryErr)
}
// Abort if no albums with a matching UID were found.
if len(albums) == 0 {
AbortEntityNotFound(c)
return
}
deleted := 0
conf := get.Config()
// Flag matching albums as deleted.
for _, a := range albums {
if deleteErr := a.Delete(); deleteErr != nil {
log.Errorf("albums: %s (delete)", deleteErr)
} else {
if conf.BackupAlbums() {
SaveAlbumYaml(a)
}
deleted++
}
}
// Update client config if at least one album was successfully deleted.
if deleted > 0 {
UpdateClientConfig()
}
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgAlbumsDeleted))
})
}
// BatchPhotosPrivate toggles private state of multiple pictures.
//
// @Summary toggles private state of multiple pictures
// @Id BatchPhotosPrivate
// @Tags Photos
// @Accept json
// @Produce json
// @Success 200 {object} i18n.Response
// @Failure 400,401,403,404,429,500 {object} i18n.Response
// @Param photos body form.Selection true "Photo Selection"
// @Router /api/v1/batch/photos/private [post]
func BatchPhotosPrivate(router *gin.RouterGroup) {
router.POST("/batch/photos/private", func(c *gin.Context) {
s := Auth(c, acl.ResourcePhotos, acl.AccessPrivate)
if s.Abort(c) {
return
}
var frm form.Selection
if err := c.BindJSON(&frm); err != nil {
AbortBadRequest(c)
return
}
if len(frm.Photos) == 0 {
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
return
}
log.Infof("photos: updating private flag for %s", clean.Log(frm.String()))
if err := entity.Db().Model(entity.Photo{}).Where("photo_uid IN (?)", frm.Photos).UpdateColumn("photo_private",
gorm.Expr("CASE WHEN photo_private > 0 THEN 0 ELSE 1 END")).Error; err != nil {
log.Errorf("private: %s", err)
AbortSaveFailed(c)
return
}
// Update precalculated photo and file counts.
entity.UpdateCountsAsync()
// Fetch selection from index.
if photos, err := query.SelectedPhotos(frm); err == nil {
for _, p := range photos {
SaveSidecarYaml(p)
}
event.EntitiesUpdated("photos", photos)
}
UpdateClientConfig()
FlushCoverCache()
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgSelectionProtected))
})
}
// BatchLabelsDelete deletes multiple labels.
//
// @Summary deletes multiple labels
// @Id BatchLabelsDelete
// @Tags Labels
// @Accept json
// @Produce json
// @Success 200 {object} i18n.Response
// @Failure 400,401,403,429,500 {object} i18n.Response
// @Param labels body form.Selection true "Label Selection"
// @Router /api/v1/batch/labels/delete [post]
func BatchLabelsDelete(router *gin.RouterGroup) {
router.POST("/batch/labels/delete", func(c *gin.Context) {
s := Auth(c, acl.ResourceLabels, acl.ActionDelete)
if s.Abort(c) {
return
}
var frm form.Selection
if err := c.BindJSON(&frm); err != nil {
AbortBadRequest(c)
return
}
if len(frm.Labels) == 0 {
log.Error("no labels selected")
Abort(c, http.StatusBadRequest, i18n.ErrNoLabelsSelected)
return
}
log.Infof("labels: deleting %s", clean.Log(frm.String()))
var labels entity.Labels
if err := entity.Db().Where("label_uid IN (?)", frm.Labels).Find(&labels).Error; err != nil {
Error(c, http.StatusInternalServerError, err, i18n.ErrDeleteFailed)
return
}
for _, label := range labels {
logErr("labels", label.Delete())
}
UpdateClientConfig()
event.EntitiesDeleted("labels", frm.Labels)
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgLabelsDeleted))
})
}
// BatchPhotosDelete permanently removes multiple pictures from the archive.
//
// @Summary permanently removes multiple or all photos from the archive
// @Id BatchPhotosDelete
// @Tags Photos
// @Accept json
// @Produce json
// @Success 200 {object} i18n.Response
// @Failure 400,401,403,429 {object} i18n.Response
// @Param photos body form.Selection true "All or Photo Selection"
// @Router /api/v1/batch/photos/delete [post]
func BatchPhotosDelete(router *gin.RouterGroup) {
router.POST("/batch/photos/delete", func(c *gin.Context) {
s := Auth(c, acl.ResourcePhotos, acl.ActionDelete)
if s.Abort(c) {
return
}
conf := get.Config()
if conf.ReadOnly() || !conf.Settings().Features.Delete {
AbortFeatureDisabled(c)
return
}
var frm form.Selection
if err := c.BindJSON(&frm); err != nil {
AbortBadRequest(c)
return
}
deleteStart := time.Now()
var photos entity.Photos
var err error
// Abort if user wants to delete all but does not have sufficient privileges.
if frm.All && !acl.Rules.AllowAll(acl.ResourcePhotos, s.UserRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) {
AbortForbidden(c)
return
}
// Get selection or all archived photos if f.All is true.
if len(frm.Photos) == 0 && !frm.All {
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
return
} else if frm.All {
photos, err = query.ArchivedPhotos(1000000, 0)
} else {
photos, err = query.SelectedPhotos(frm)
}
// Abort if the query failed or no photos were found.
if err != nil {
log.Errorf("archive: %s", err)
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
return
} else if len(photos) > 0 {
log.Infof("archive: deleting %s", english.Plural(len(photos), "photo", "photos"))
} else {
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
return
}
var deleted entity.Photos
var numFiles = 0
// Delete photos.
for _, p := range photos {
// Report file deletion.
event.AuditWarn([]string{ClientIP(c), s.UserName, "delete", path.Join(p.PhotoPath, p.PhotoName+"*")})
// Remove all related files from storage.
n, deleteErr := photoprism.DeletePhoto(p, true, true)
numFiles += n
if deleteErr != nil {
log.Errorf("delete: %s", deleteErr)
} else {
deleted = append(deleted, p)
}
}
if numFiles > 0 || len(deleted) > 0 {
log.Infof("archive: deleted %s and %s [%s]", english.Plural(numFiles, "file", "files"), english.Plural(len(deleted), "photo", "photos"), time.Since(deleteStart))
}
// Any photos deleted?
if len(deleted) > 0 {
config.FlushUsageCache()
// Update precalculated photo and file counts.
entity.UpdateCountsAsync()
UpdateClientConfig()
event.EntitiesDeleted("photos", deleted.UIDs())
}
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgPermanentlyDeleted))
})
}

View File

@@ -0,0 +1,88 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/auth/acl"
"github.com/photoprism/photoprism/internal/entity/query"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/i18n"
)
// BatchAlbumsDelete permanently removes multiple albums.
//
// @Summary permanently removes multiple albums
// @Id BatchAlbumsDelete
// @Tags Albums
// @Accept json
// @Produce json
// @Success 200 {object} i18n.Response
// @Failure 400,401,403,404,429 {object} i18n.Response
// @Param albums body form.Selection true "Album Selection"
// @Router /api/v1/batch/albums/delete [post]
func BatchAlbumsDelete(router *gin.RouterGroup) {
router.POST("/batch/albums/delete", func(c *gin.Context) {
s := Auth(c, acl.ResourceAlbums, acl.ActionDelete)
if s.Abort(c) {
return
}
var frm form.Selection
if err := c.BindJSON(&frm); err != nil {
AbortBadRequest(c)
return
}
// Get album UIDs.
albumUIDs := frm.Albums
if len(albumUIDs) == 0 {
Abort(c, http.StatusBadRequest, i18n.ErrNoAlbumsSelected)
return
}
log.Infof("albums: deleting %s", clean.Log(frm.String()))
// Fetch albums.
albums, queryErr := query.AlbumsByUID(albumUIDs, false)
if queryErr != nil {
log.Errorf("albums: %s (find)", queryErr)
}
// Abort if no albums with a matching UID were found.
if len(albums) == 0 {
AbortEntityNotFound(c)
return
}
deleted := 0
conf := get.Config()
// Flag matching albums as deleted.
for _, a := range albums {
if deleteErr := a.Delete(); deleteErr != nil {
log.Errorf("albums: %s (delete)", deleteErr)
} else {
if conf.BackupAlbums() {
SaveAlbumYaml(a)
}
deleted++
}
}
// Update client config if at least one album was successfully deleted.
if deleted > 0 {
UpdateClientConfig()
}
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgAlbumsDeleted))
})
}

View File

@@ -0,0 +1,67 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/auth/acl"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/i18n"
)
// BatchLabelsDelete deletes multiple labels.
//
// @Summary deletes multiple labels
// @Id BatchLabelsDelete
// @Tags Labels
// @Accept json
// @Produce json
// @Success 200 {object} i18n.Response
// @Failure 400,401,403,429,500 {object} i18n.Response
// @Param labels body form.Selection true "Label Selection"
// @Router /api/v1/batch/labels/delete [post]
func BatchLabelsDelete(router *gin.RouterGroup) {
router.POST("/batch/labels/delete", func(c *gin.Context) {
s := Auth(c, acl.ResourceLabels, acl.ActionDelete)
if s.Abort(c) {
return
}
var frm form.Selection
if err := c.BindJSON(&frm); err != nil {
AbortBadRequest(c)
return
}
if len(frm.Labels) == 0 {
log.Error("no labels selected")
Abort(c, http.StatusBadRequest, i18n.ErrNoLabelsSelected)
return
}
log.Infof("labels: deleting %s", clean.Log(frm.String()))
var labels entity.Labels
if err := entity.Db().Where("label_uid IN (?)", frm.Labels).Find(&labels).Error; err != nil {
Error(c, http.StatusInternalServerError, err, i18n.ErrDeleteFailed)
return
}
for _, label := range labels {
logErr("labels", label.Delete())
}
UpdateClientConfig()
event.EntitiesDeleted("labels", frm.Labels)
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgLabelsDeleted))
})
}

View File

@@ -2,44 +2,45 @@ package api
import ( import (
"net/http" "net/http"
"path"
"time"
"github.com/dustin/go-humanize/english"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/internal/auth/acl" "github.com/photoprism/photoprism/internal/auth/acl"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/entity/query" "github.com/photoprism/photoprism/internal/entity/query"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/form/batch" "github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/photoprism/get" "github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/i18n" "github.com/photoprism/photoprism/pkg/i18n"
) )
// BatchPhotos returns the metadata of multiple pictures so that it can be edited. // BatchPhotosArchive moves multiple pictures to the archive.
// //
// @Summary returns the metadata of multiple pictures so that it can be edited // @Summary moves multiple pictures to the archive
// @Id BatchPhotos // @Id BatchPhotosArchive
// @Tags Photos // @Tags Photos
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Success 200 {object} batch.PhotoForm // @Success 200 {object} i18n.Response
// @Failure 400,401,403,404,429,500 {object} i18n.Response // @Failure 400,401,403,404,429,500 {object} i18n.Response
// @Param photos body form.Selection true "Photo Selection" // @Param photos body form.Selection true "Photo Selection"
// @Router /api/v1/batch/photos [post] // @Router /api/v1/batch/photos/archive [post]
func BatchPhotos(router *gin.RouterGroup) { func BatchPhotosArchive(router *gin.RouterGroup) {
router.POST("/batch/photos", func(c *gin.Context) { router.POST("/batch/photos/archive", func(c *gin.Context) {
s := Auth(c, acl.ResourcePhotos, acl.ActionUpdate) s := Auth(c, acl.ResourcePhotos, acl.ActionDelete)
if s.Abort(c) { if s.Abort(c) {
return return
} }
conf := get.Config()
if !conf.Develop() && !conf.Experimental() {
AbortNotImplemented(c)
return
}
var frm form.Selection var frm form.Selection
// Assign and validate request form values. // Assign and validate request form values.
@@ -53,28 +54,339 @@ func BatchPhotos(router *gin.RouterGroup) {
return return
} }
// Find selected photos. log.Infof("photos: archiving %s", clean.Log(frm.String()))
photos, err := query.SelectedPhotos(frm)
if err != nil { if get.Config().SidecarYaml() {
log.Errorf("batch: %s", clean.Error(err)) // Fetch selection from index.
AbortUnexpectedError(c) photos, err := query.SelectedPhotos(frm)
if err != nil {
AbortEntityNotFound(c)
return
}
for _, p := range photos {
if archiveErr := p.Archive(); archiveErr != nil {
log.Errorf("archive: %s", archiveErr)
} else {
SaveSidecarYaml(p)
}
}
} else if err := entity.Db().Where("photo_uid IN (?)", frm.Photos).Delete(&entity.Photo{}).Error; err != nil {
log.Errorf("archive: failed to archive %d pictures (%s)", len(frm.Photos), err)
AbortSaveFailed(c)
return
} else if err = entity.Db().Model(&entity.PhotoAlbum{}).Where("photo_uid IN (?)", frm.Photos).UpdateColumn("hidden", true).Error; err != nil {
log.Errorf("archive: failed to flag %d pictures as hidden (%s)", len(frm.Photos), err)
}
// Update precalculated photo and file counts.
entity.UpdateCountsAsync()
// Update album, subject, and label cover thumbs.
query.UpdateCoversAsync()
UpdateClientConfig()
event.EntitiesArchived("photos", frm.Photos)
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgSelectionArchived))
})
}
// BatchPhotosRestore restores multiple pictures from the archive.
//
// @Summary restores multiple pictures from the archive
// @Id BatchPhotosRestore
// @Tags Photos
// @Accept json
// @Produce json
// @Success 200 {object} i18n.Response
// @Failure 400,401,403,404,429,500 {object} i18n.Response
// @Param photos body form.Selection true "Photo Selection"
// @Router /api/v1/batch/photos/restore [post]
func BatchPhotosRestore(router *gin.RouterGroup) {
router.POST("/batch/photos/restore", func(c *gin.Context) {
s := Auth(c, acl.ResourcePhotos, acl.ActionDelete)
if s.Abort(c) {
return return
} }
// Load files and details. var frm form.Selection
for _, photo := range photos {
photo.PreloadFiles() if err := c.BindJSON(&frm); err != nil {
photo.GetDetails() AbortBadRequest(c)
return
} }
batchFrm := batch.NewPhotoForm(photos) if len(frm.Photos) == 0 {
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
data := gin.H{ return
"photos": photos,
"values": batchFrm,
} }
c.JSON(http.StatusOK, data) log.Infof("photos: restoring %s", clean.Log(frm.String()))
if get.Config().SidecarYaml() {
// Fetch selection from index.
photos, err := query.SelectedPhotos(frm)
if err != nil {
AbortEntityNotFound(c)
return
}
for _, p := range photos {
if err = p.Restore(); err != nil {
log.Errorf("restore: %s", err)
} else {
SaveSidecarYaml(p)
}
}
} else if err := entity.Db().Unscoped().Model(&entity.Photo{}).Where("photo_uid IN (?)", frm.Photos).
UpdateColumn("deleted_at", gorm.Expr("NULL")).Error; err != nil {
log.Errorf("restore: %s", err)
AbortSaveFailed(c)
return
}
// Update precalculated photo and file counts.
entity.UpdateCountsAsync()
// Update album, subject, and label cover thumbs.
query.UpdateCoversAsync()
UpdateClientConfig()
event.EntitiesRestored("photos", frm.Photos)
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgSelectionRestored))
})
}
// BatchPhotosApprove approves multiple pictures that are currently under review.
//
// @Summary approves multiple pictures that are currently under review
// @Id BatchPhotosApprove
// @Tags Photos
// @Accept json
// @Produce json
// @Success 200 {object} i18n.Response
// @Failure 400,401,403,404,429 {object} i18n.Response
// @Param photos body form.Selection true "Photo Selection"
// @Router /api/v1/batch/photos/approve [post]
func BatchPhotosApprove(router *gin.RouterGroup) {
router.POST("batch/photos/approve", func(c *gin.Context) {
s := Auth(c, acl.ResourcePhotos, acl.ActionUpdate)
if s.Abort(c) {
return
}
var frm form.Selection
if err := c.BindJSON(&frm); err != nil {
AbortBadRequest(c)
return
}
if len(frm.Photos) == 0 {
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
return
}
log.Infof("photos: approving %s", clean.Log(frm.String()))
// Fetch selection from index.
photos, err := query.SelectedPhotos(frm)
if err != nil {
AbortEntityNotFound(c)
return
}
var approved entity.Photos
for _, p := range photos {
if err = p.Approve(); err != nil {
log.Errorf("approve: %s", err)
} else {
approved = append(approved, p)
SaveSidecarYaml(p)
}
}
UpdateClientConfig()
event.EntitiesUpdated("photos", approved)
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgSelectionApproved))
})
}
// BatchPhotosPrivate toggles private state of multiple pictures.
//
// @Summary toggles private state of multiple pictures
// @Id BatchPhotosPrivate
// @Tags Photos
// @Accept json
// @Produce json
// @Success 200 {object} i18n.Response
// @Failure 400,401,403,404,429,500 {object} i18n.Response
// @Param photos body form.Selection true "Photo Selection"
// @Router /api/v1/batch/photos/private [post]
func BatchPhotosPrivate(router *gin.RouterGroup) {
router.POST("/batch/photos/private", func(c *gin.Context) {
s := Auth(c, acl.ResourcePhotos, acl.AccessPrivate)
if s.Abort(c) {
return
}
var frm form.Selection
if err := c.BindJSON(&frm); err != nil {
AbortBadRequest(c)
return
}
if len(frm.Photos) == 0 {
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
return
}
log.Infof("photos: updating private flag for %s", clean.Log(frm.String()))
if err := entity.Db().Model(entity.Photo{}).Where("photo_uid IN (?)", frm.Photos).UpdateColumn("photo_private",
gorm.Expr("CASE WHEN photo_private > 0 THEN 0 ELSE 1 END")).Error; err != nil {
log.Errorf("private: %s", err)
AbortSaveFailed(c)
return
}
// Update precalculated photo and file counts.
entity.UpdateCountsAsync()
// Fetch selection from index.
if photos, err := query.SelectedPhotos(frm); err == nil {
for _, p := range photos {
SaveSidecarYaml(p)
}
event.EntitiesUpdated("photos", photos)
}
UpdateClientConfig()
FlushCoverCache()
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgSelectionProtected))
})
}
// BatchPhotosDelete permanently removes multiple pictures from the archive.
//
// @Summary permanently removes multiple or all photos from the archive
// @Id BatchPhotosDelete
// @Tags Photos
// @Accept json
// @Produce json
// @Success 200 {object} i18n.Response
// @Failure 400,401,403,429 {object} i18n.Response
// @Param photos body form.Selection true "All or Photo Selection"
// @Router /api/v1/batch/photos/delete [post]
func BatchPhotosDelete(router *gin.RouterGroup) {
router.POST("/batch/photos/delete", func(c *gin.Context) {
s := Auth(c, acl.ResourcePhotos, acl.ActionDelete)
if s.Abort(c) {
return
}
conf := get.Config()
if conf.ReadOnly() || !conf.Settings().Features.Delete {
AbortFeatureDisabled(c)
return
}
var frm form.Selection
if err := c.BindJSON(&frm); err != nil {
AbortBadRequest(c)
return
}
deleteStart := time.Now()
var photos entity.Photos
var err error
// Abort if user wants to delete all but does not have sufficient privileges.
if frm.All && !acl.Rules.AllowAll(acl.ResourcePhotos, s.UserRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) {
AbortForbidden(c)
return
}
// Get selection or all archived photos if f.All is true.
if len(frm.Photos) == 0 && !frm.All {
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
return
} else if frm.All {
photos, err = query.ArchivedPhotos(1000000, 0)
} else {
photos, err = query.SelectedPhotos(frm)
}
// Abort if the query failed or no photos were found.
if err != nil {
log.Errorf("archive: %s", err)
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
return
} else if len(photos) > 0 {
log.Infof("archive: deleting %s", english.Plural(len(photos), "photo", "photos"))
} else {
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
return
}
var deleted entity.Photos
var numFiles = 0
// Delete photos.
for _, p := range photos {
// Report file deletion.
event.AuditWarn([]string{ClientIP(c), s.UserName, "delete", path.Join(p.PhotoPath, p.PhotoName+"*")})
// Remove all related files from storage.
n, deleteErr := photoprism.DeletePhoto(p, true, true)
numFiles += n
if deleteErr != nil {
log.Errorf("delete: %s", deleteErr)
} else {
deleted = append(deleted, p)
}
}
if numFiles > 0 || len(deleted) > 0 {
log.Infof("archive: deleted %s and %s [%s]", english.Plural(numFiles, "file", "files"), english.Plural(len(deleted), "photo", "photos"), time.Since(deleteStart))
}
// Any photos deleted?
if len(deleted) > 0 {
config.FlushUsageCache()
// Update precalculated photo and file counts.
entity.UpdateCountsAsync()
UpdateClientConfig()
event.EntitiesDeleted("photos", deleted.UIDs())
}
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgPermanentlyDeleted))
}) })
} }

View File

@@ -0,0 +1,94 @@
package api
import (
"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/search"
"github.com/photoprism/photoprism/internal/form/batch"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/i18n"
)
// BatchPhotosEdit returns the metadata of multiple pictures so that it can be edited.
//
// @Summary returns the metadata of multiple pictures so that it can be edited
// @Id BatchPhotosEdit
// @Tags Photos
// @Accept json
// @Produce json
// @Success 200 {object} batch.PhotosForm
// @Failure 400,401,403,404,429,500 {object} i18n.Response
// @Param photos body batch.PhotosRequest true "photos selection and values"
// @Router /api/v1/batch/photos/edit [post]
func BatchPhotosEdit(router *gin.RouterGroup) {
router.POST("/batch/photos/edit", func(c *gin.Context) {
s := Auth(c, acl.ResourcePhotos, acl.ActionUpdate)
if s.Abort(c) {
return
}
conf := get.Config()
if !conf.Develop() && !conf.Experimental() {
AbortNotImplemented(c)
return
}
var frm batch.PhotosRequest
// Assign and validate request form values.
if err := c.BindJSON(&frm); err != nil {
AbortBadRequest(c)
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", 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)
}
}
// Create batch edit form values form from photo metadata.
batchFrm := batch.NewPhotosForm(photos)
var data gin.H
if frm.Return {
data = gin.H{
"photos": photos,
"values": batchFrm,
}
} else {
data = gin.H{
"values": batchFrm,
}
}
c.JSON(http.StatusOK, data)
})
}

View File

@@ -0,0 +1,79 @@
package api
import (
"fmt"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/tidwall/gjson"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/i18n"
)
func TestBatchPhotosEdit(t *testing.T) {
t.Run("ReturnValues", func(t *testing.T) {
app, router, _ := NewApiTest()
BatchPhotosEdit(router)
response := PerformRequestWithBody(app,
"POST", "/api/v1/batch/photos/edit",
`{"photos": ["ps6sg6be2lvl0yh7", "ps6sg6be2lvl0yh8"]}`,
)
body := response.Body.String()
assert.NotEmpty(t, body)
assert.True(t, strings.HasPrefix(body, `{"values":{"`), "unexpected response")
// fmt.Println(body)
/* photos := gjson.Get(body, "photos")
values := gjson.Get(body, "values")
t.Logf("photos: %#v", photos)
t.Logf("values: %#v", values) */
assert.Equal(t, http.StatusOK, response.Code)
})
t.Run("ReturnPhotosAndValues", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
authToken := AuthenticateUser(app, router, "alice", "Alice123!")
BatchPhotosEdit(router)
response := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/batch/photos/edit",
`{"photos": ["ps6sg6be2lvl0yh7","ps6sg6be2lvl0yh8","ps6sg6byk7wrbk47","ps6sg6be2lvl0yh0"], "return": true, "values": {}}`,
authToken)
body := response.Body.String()
assert.NotEmpty(t, body)
assert.True(t, strings.HasPrefix(body, `{"photos":[{"ID"`), "unexpected response")
fmt.Println(body)
/* photos := gjson.Get(body, "photos")
values := gjson.Get(body, "values")
t.Logf("photos: %#v", photos)
t.Logf("values: %#v", values) */
assert.Equal(t, http.StatusOK, response.Code)
})
t.Run("MissingSelection", func(t *testing.T) {
app, router, _ := NewApiTest()
BatchPhotosEdit(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/edit", `{"photos": [], "return": true}`)
val := gjson.Get(r.Body.String(), "error")
assert.Equal(t, i18n.Msg(i18n.ErrNoItemsSelected), val.String())
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("InvalidRequest", func(t *testing.T) {
app, router, _ := NewApiTest()
BatchPhotosEdit(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/edit", `{"photos": 123, "return": true}`)
assert.Equal(t, http.StatusBadRequest, r.Code)
})
}

View File

@@ -1,8 +1,9 @@
package api package api
import ( import (
"encoding/json"
"fmt"
"net/http" "net/http"
"strings"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -11,42 +12,266 @@ import (
"github.com/photoprism/photoprism/pkg/i18n" "github.com/photoprism/photoprism/pkg/i18n"
) )
func TestBatchPhotos(t *testing.T) { func TestBatchPhotosArchive(t *testing.T) {
t.Run("Success", func(t *testing.T) { t.Run("Success", func(t *testing.T) {
app, router, _ := NewApiTest() app, router, _ := NewApiTest()
GetPhoto(router)
r := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0yh7")
assert.Equal(t, http.StatusOK, r.Code)
val := gjson.Get(r.Body.String(), "DeletedAt")
assert.Empty(t, val.String())
BatchPhotos(router) BatchPhotosArchive(router)
r2 := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/archive", `{"photos": ["ps6sg6be2lvl0yh7", "ps6sg6be2lvl0ycc"]}`)
val2 := gjson.Get(r2.Body.String(), "message")
assert.Contains(t, val2.String(), "Selection archived")
assert.Equal(t, http.StatusOK, r2.Code)
response := PerformRequestWithBody(app, r3 := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0yh7")
"POST", "/api/v1/batch/photos", assert.Equal(t, http.StatusOK, r3.Code)
`{"photos": ["ps6sg6be2lvl0yh7", "ps6sg6be2lvl0yh8", "ps6sg6be2lvl0ycc"]}`, val3 := gjson.Get(r3.Body.String(), "DeletedAt")
) assert.NotEmpty(t, val3.String())
body := response.Body.String()
assert.NotEmpty(t, body)
assert.True(t, strings.HasPrefix(body, `{"photos":[{"ID"`), "unexpected response")
// fmt.Println(body)
/* photos := gjson.Get(body, "photos")
values := gjson.Get(body, "values")
t.Logf("photos: %#v", photos)
t.Logf("values: %#v", values) */
assert.Equal(t, http.StatusOK, response.Code)
}) })
t.Run("MissingSelection", func(t *testing.T) { t.Run("MissingSelection", func(t *testing.T) {
app, router, _ := NewApiTest() app, router, _ := NewApiTest()
BatchPhotos(router) BatchPhotosArchive(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos", `{"photos": []}`) r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/archive", `{"photos": []}`)
val := gjson.Get(r.Body.String(), "error") val := gjson.Get(r.Body.String(), "error")
assert.Equal(t, i18n.Msg(i18n.ErrNoItemsSelected), val.String()) assert.Equal(t, i18n.Msg(i18n.ErrNoItemsSelected), val.String())
assert.Equal(t, http.StatusBadRequest, r.Code) assert.Equal(t, http.StatusBadRequest, r.Code)
}) })
t.Run("InvalidRequest", func(t *testing.T) { t.Run("InvalidRequest", func(t *testing.T) {
app, router, _ := NewApiTest() app, router, _ := NewApiTest()
BatchPhotos(router) BatchPhotosArchive(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos", `{"photos": 123}`) r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/archive", `{"photos": 123}`)
assert.Equal(t, http.StatusBadRequest, r.Code)
})
}
func TestBatchPhotosRestore(t *testing.T) {
t.Run("Success", func(t *testing.T) {
app, router, _ := NewApiTest()
// Register routes.
BatchPhotosArchive(router)
GetPhoto(router)
BatchPhotosRestore(router)
r2 := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/archive", `{"photos": ["ps6sg6be2lvl0yh8", "ps6sg6be2lvl0ycc"]}`)
val2 := gjson.Get(r2.Body.String(), "message")
assert.Contains(t, val2.String(), "Selection archived")
assert.Equal(t, http.StatusOK, r2.Code)
r3 := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0yh8")
assert.Equal(t, http.StatusOK, r3.Code)
val3 := gjson.Get(r3.Body.String(), "DeletedAt")
assert.NotEmpty(t, val3.String())
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/restore", `{"photos": ["ps6sg6be2lvl0yh8", "ps6sg6be2lvl0ycc"]}`)
val := gjson.Get(r.Body.String(), "message")
assert.Contains(t, val.String(), "Selection restored")
assert.Equal(t, http.StatusOK, r.Code)
r4 := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0yh8")
assert.Equal(t, http.StatusOK, r4.Code)
val4 := gjson.Get(r4.Body.String(), "DeletedAt")
assert.Empty(t, val4.String())
})
t.Run("MissingSelection", func(t *testing.T) {
app, router, _ := NewApiTest()
BatchPhotosRestore(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/restore", `{"photos": []}`)
val := gjson.Get(r.Body.String(), "error")
assert.Equal(t, i18n.Msg(i18n.ErrNoItemsSelected), val.String())
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("InvalidRequest", func(t *testing.T) {
app, router, _ := NewApiTest()
BatchPhotosRestore(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/restore", `{"photos": 123}`)
assert.Equal(t, http.StatusBadRequest, r.Code)
})
}
func TestBatchAlbumsDelete(t *testing.T) {
app, router, _ := NewApiTest()
CreateAlbum(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/albums", `{"Title": "BatchDelete", "Description": "To be deleted", "Notes": "", "Favorite": true}`)
assert.Equal(t, http.StatusOK, r.Code)
uid := gjson.Get(r.Body.String(), "UID").String()
t.Run("Success", func(t *testing.T) {
app, router, _ := NewApiTest()
// Register routes.
GetAlbum(router)
BatchAlbumsDelete(router)
r := PerformRequest(app, "GET", "/api/v1/albums/"+uid)
val := gjson.Get(r.Body.String(), "Slug")
assert.Equal(t, "batchdelete", val.String())
r2 := PerformRequestWithBody(app, "POST", "/api/v1/batch/albums/delete", fmt.Sprintf(`{"albums": ["%s", "ps6sg6be2lvl0ycc"]}`, uid))
val2 := gjson.Get(r2.Body.String(), "message")
assert.Contains(t, val2.String(), i18n.Msg(i18n.MsgAlbumsDeleted))
assert.Equal(t, http.StatusOK, r2.Code)
r3 := PerformRequest(app, "GET", "/api/v1/albums/"+uid)
val3 := gjson.Get(r3.Body.String(), "error")
assert.Equal(t, i18n.Msg(i18n.ErrAlbumNotFound), val3.String())
assert.Equal(t, http.StatusNotFound, r3.Code)
})
t.Run("no albums selected", func(t *testing.T) {
app, router, _ := NewApiTest()
BatchAlbumsDelete(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/albums/delete", `{"albums": []}`)
val := gjson.Get(r.Body.String(), "error")
assert.Equal(t, i18n.Msg(i18n.ErrNoAlbumsSelected), val.String())
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("InvalidRequest", func(t *testing.T) {
app, router, _ := NewApiTest()
BatchAlbumsDelete(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/albums/delete", `{"albums": 123}`)
assert.Equal(t, http.StatusBadRequest, r.Code)
})
}
func TestBatchPhotosPrivate(t *testing.T) {
t.Run("Success", func(t *testing.T) {
app, router, _ := NewApiTest()
// Register routes.
GetPhoto(router)
BatchPhotosPrivate(router)
r := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0yh8")
assert.Equal(t, http.StatusOK, r.Code)
val := gjson.Get(r.Body.String(), "Private")
assert.Equal(t, "false", val.String())
r2 := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/private", `{"photos": ["ps6sg6be2lvl0yh8", "ps6sg6be2lvl0ycc"]}`)
val2 := gjson.Get(r2.Body.String(), "message")
assert.Contains(t, val2.String(), "Selection marked as private")
assert.Equal(t, http.StatusOK, r2.Code)
r3 := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0yh8")
assert.Equal(t, http.StatusOK, r3.Code)
val3 := gjson.Get(r3.Body.String(), "Private")
assert.Equal(t, "true", val3.String())
})
t.Run("MissingSelection", func(t *testing.T) {
app, router, _ := NewApiTest()
BatchPhotosPrivate(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/private", `{"photos": []}`)
val := gjson.Get(r.Body.String(), "error")
assert.Equal(t, i18n.Msg(i18n.ErrNoItemsSelected), val.String())
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("InvalidRequest", func(t *testing.T) {
app, router, _ := NewApiTest()
BatchPhotosPrivate(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/private", `{"photos": 123}`)
assert.Equal(t, http.StatusBadRequest, r.Code)
})
}
func TestBatchLabelsDelete(t *testing.T) {
t.Run("Success", func(t *testing.T) {
app, router, _ := NewApiTest()
// Register routes.
SearchLabels(router)
BatchLabelsDelete(router)
r := PerformRequest(app, "GET", "/api/v1/labels?count=15")
val := gjson.Get(r.Body.String(), `#(Name=="Batch Delete").Slug`)
assert.Equal(t, val.String(), "batch-delete")
r2 := PerformRequestWithBody(app, "POST", "/api/v1/batch/labels/delete", `{"labels": ["ls6sg6b1wowuy3c6", "ps6sg6be2lvl0ycc"]}`)
var resp i18n.Response
if err := json.Unmarshal(r2.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
assert.True(t, resp.Success())
assert.Equal(t, i18n.Msg(i18n.MsgLabelsDeleted), resp.Msg)
assert.Equal(t, i18n.Msg(i18n.MsgLabelsDeleted), resp.String())
assert.Equal(t, http.StatusOK, r2.Code)
assert.Equal(t, http.StatusOK, resp.Code)
r3 := PerformRequest(app, "GET", "/api/v1/labels?count=15")
val3 := gjson.Get(r3.Body.String(), `#(Name=="BatchDelete").Slug`)
assert.Equal(t, val3.String(), "")
})
t.Run("no labels selected", func(t *testing.T) {
app, router, _ := NewApiTest()
BatchLabelsDelete(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/labels/delete", `{"labels": []}`)
val := gjson.Get(r.Body.String(), "error")
assert.Equal(t, i18n.Msg(i18n.ErrNoLabelsSelected), val.String())
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("InvalidRequest", func(t *testing.T) {
app, router, _ := NewApiTest()
BatchLabelsDelete(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/labels/delete", `{"labels": 123}`)
assert.Equal(t, http.StatusBadRequest, r.Code)
})
}
func TestBatchPhotosApprove(t *testing.T) {
t.Run("Success", func(t *testing.T) {
app, router, _ := NewApiTest()
// Register routes.
GetPhoto(router)
BatchPhotosApprove(router)
r := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0y50")
assert.Equal(t, http.StatusOK, r.Code)
val := gjson.Get(r.Body.String(), "Quality")
assert.Equal(t, "1", val.String())
val4 := gjson.Get(r.Body.String(), "EditedAt")
assert.Empty(t, val4.String())
r2 := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/approve", `{"photos": ["ps6sg6be2lvl0y50", "ps6sg6be2lvl0y90"]}`)
val2 := gjson.Get(r2.Body.String(), "message")
assert.Contains(t, val2.String(), "Selection approved")
assert.Equal(t, http.StatusOK, r2.Code)
r3 := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0y50")
assert.Equal(t, http.StatusOK, r3.Code)
val5 := gjson.Get(r3.Body.String(), "Quality")
assert.Equal(t, "7", val5.String())
val6 := gjson.Get(r3.Body.String(), "EditedAt")
assert.NotEmpty(t, val6.String())
})
t.Run("MissingSelection", func(t *testing.T) {
app, router, _ := NewApiTest()
BatchPhotosApprove(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/approve", `{"photos": []}`)
val := gjson.Get(r.Body.String(), "error")
assert.Equal(t, i18n.Msg(i18n.ErrNoItemsSelected), val.String())
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("InvalidRequest", func(t *testing.T) {
app, router, _ := NewApiTest()
BatchPhotosApprove(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/approve", `{"photos": 123}`)
assert.Equal(t, http.StatusBadRequest, r.Code)
})
}
func TestBatchPhotosDelete(t *testing.T) {
t.Run("ErrNoItemsSelected", func(t *testing.T) {
app, router, _ := NewApiTest()
BatchPhotosDelete(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/delete", `{"photos": []}`)
val := gjson.Get(r.Body.String(), "error")
assert.Equal(t, i18n.Msg(i18n.ErrNoItemsSelected), val.String())
assert.Equal(t, http.StatusBadRequest, r.Code) assert.Equal(t, http.StatusBadRequest, r.Code)
}) })
} }

View File

@@ -1,277 +0,0 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/tidwall/gjson"
"github.com/photoprism/photoprism/pkg/i18n"
)
func TestBatchPhotosArchive(t *testing.T) {
t.Run("Success", func(t *testing.T) {
app, router, _ := NewApiTest()
GetPhoto(router)
r := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0yh7")
assert.Equal(t, http.StatusOK, r.Code)
val := gjson.Get(r.Body.String(), "DeletedAt")
assert.Empty(t, val.String())
BatchPhotosArchive(router)
r2 := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/archive", `{"photos": ["ps6sg6be2lvl0yh7", "ps6sg6be2lvl0ycc"]}`)
val2 := gjson.Get(r2.Body.String(), "message")
assert.Contains(t, val2.String(), "Selection archived")
assert.Equal(t, http.StatusOK, r2.Code)
r3 := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0yh7")
assert.Equal(t, http.StatusOK, r3.Code)
val3 := gjson.Get(r3.Body.String(), "DeletedAt")
assert.NotEmpty(t, val3.String())
})
t.Run("MissingSelection", func(t *testing.T) {
app, router, _ := NewApiTest()
BatchPhotosArchive(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/archive", `{"photos": []}`)
val := gjson.Get(r.Body.String(), "error")
assert.Equal(t, i18n.Msg(i18n.ErrNoItemsSelected), val.String())
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("InvalidRequest", func(t *testing.T) {
app, router, _ := NewApiTest()
BatchPhotosArchive(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/archive", `{"photos": 123}`)
assert.Equal(t, http.StatusBadRequest, r.Code)
})
}
func TestBatchPhotosRestore(t *testing.T) {
t.Run("Success", func(t *testing.T) {
app, router, _ := NewApiTest()
// Register routes.
BatchPhotosArchive(router)
GetPhoto(router)
BatchPhotosRestore(router)
r2 := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/archive", `{"photos": ["ps6sg6be2lvl0yh8", "ps6sg6be2lvl0ycc"]}`)
val2 := gjson.Get(r2.Body.String(), "message")
assert.Contains(t, val2.String(), "Selection archived")
assert.Equal(t, http.StatusOK, r2.Code)
r3 := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0yh8")
assert.Equal(t, http.StatusOK, r3.Code)
val3 := gjson.Get(r3.Body.String(), "DeletedAt")
assert.NotEmpty(t, val3.String())
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/restore", `{"photos": ["ps6sg6be2lvl0yh8", "ps6sg6be2lvl0ycc"]}`)
val := gjson.Get(r.Body.String(), "message")
assert.Contains(t, val.String(), "Selection restored")
assert.Equal(t, http.StatusOK, r.Code)
r4 := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0yh8")
assert.Equal(t, http.StatusOK, r4.Code)
val4 := gjson.Get(r4.Body.String(), "DeletedAt")
assert.Empty(t, val4.String())
})
t.Run("MissingSelection", func(t *testing.T) {
app, router, _ := NewApiTest()
BatchPhotosRestore(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/restore", `{"photos": []}`)
val := gjson.Get(r.Body.String(), "error")
assert.Equal(t, i18n.Msg(i18n.ErrNoItemsSelected), val.String())
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("InvalidRequest", func(t *testing.T) {
app, router, _ := NewApiTest()
BatchPhotosRestore(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/restore", `{"photos": 123}`)
assert.Equal(t, http.StatusBadRequest, r.Code)
})
}
func TestBatchAlbumsDelete(t *testing.T) {
app, router, _ := NewApiTest()
CreateAlbum(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/albums", `{"Title": "BatchDelete", "Description": "To be deleted", "Notes": "", "Favorite": true}`)
assert.Equal(t, http.StatusOK, r.Code)
uid := gjson.Get(r.Body.String(), "UID").String()
t.Run("Success", func(t *testing.T) {
app, router, _ := NewApiTest()
// Register routes.
GetAlbum(router)
BatchAlbumsDelete(router)
r := PerformRequest(app, "GET", "/api/v1/albums/"+uid)
val := gjson.Get(r.Body.String(), "Slug")
assert.Equal(t, "batchdelete", val.String())
r2 := PerformRequestWithBody(app, "POST", "/api/v1/batch/albums/delete", fmt.Sprintf(`{"albums": ["%s", "ps6sg6be2lvl0ycc"]}`, uid))
val2 := gjson.Get(r2.Body.String(), "message")
assert.Contains(t, val2.String(), i18n.Msg(i18n.MsgAlbumsDeleted))
assert.Equal(t, http.StatusOK, r2.Code)
r3 := PerformRequest(app, "GET", "/api/v1/albums/"+uid)
val3 := gjson.Get(r3.Body.String(), "error")
assert.Equal(t, i18n.Msg(i18n.ErrAlbumNotFound), val3.String())
assert.Equal(t, http.StatusNotFound, r3.Code)
})
t.Run("no albums selected", func(t *testing.T) {
app, router, _ := NewApiTest()
BatchAlbumsDelete(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/albums/delete", `{"albums": []}`)
val := gjson.Get(r.Body.String(), "error")
assert.Equal(t, i18n.Msg(i18n.ErrNoAlbumsSelected), val.String())
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("InvalidRequest", func(t *testing.T) {
app, router, _ := NewApiTest()
BatchAlbumsDelete(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/albums/delete", `{"albums": 123}`)
assert.Equal(t, http.StatusBadRequest, r.Code)
})
}
func TestBatchPhotosPrivate(t *testing.T) {
t.Run("Success", func(t *testing.T) {
app, router, _ := NewApiTest()
// Register routes.
GetPhoto(router)
BatchPhotosPrivate(router)
r := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0yh8")
assert.Equal(t, http.StatusOK, r.Code)
val := gjson.Get(r.Body.String(), "Private")
assert.Equal(t, "false", val.String())
r2 := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/private", `{"photos": ["ps6sg6be2lvl0yh8", "ps6sg6be2lvl0ycc"]}`)
val2 := gjson.Get(r2.Body.String(), "message")
assert.Contains(t, val2.String(), "Selection marked as private")
assert.Equal(t, http.StatusOK, r2.Code)
r3 := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0yh8")
assert.Equal(t, http.StatusOK, r3.Code)
val3 := gjson.Get(r3.Body.String(), "Private")
assert.Equal(t, "true", val3.String())
})
t.Run("MissingSelection", func(t *testing.T) {
app, router, _ := NewApiTest()
BatchPhotosPrivate(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/private", `{"photos": []}`)
val := gjson.Get(r.Body.String(), "error")
assert.Equal(t, i18n.Msg(i18n.ErrNoItemsSelected), val.String())
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("InvalidRequest", func(t *testing.T) {
app, router, _ := NewApiTest()
BatchPhotosPrivate(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/private", `{"photos": 123}`)
assert.Equal(t, http.StatusBadRequest, r.Code)
})
}
func TestBatchLabelsDelete(t *testing.T) {
t.Run("Success", func(t *testing.T) {
app, router, _ := NewApiTest()
// Register routes.
SearchLabels(router)
BatchLabelsDelete(router)
r := PerformRequest(app, "GET", "/api/v1/labels?count=15")
val := gjson.Get(r.Body.String(), `#(Name=="Batch Delete").Slug`)
assert.Equal(t, val.String(), "batch-delete")
r2 := PerformRequestWithBody(app, "POST", "/api/v1/batch/labels/delete", `{"labels": ["ls6sg6b1wowuy3c6", "ps6sg6be2lvl0ycc"]}`)
var resp i18n.Response
if err := json.Unmarshal(r2.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
assert.True(t, resp.Success())
assert.Equal(t, i18n.Msg(i18n.MsgLabelsDeleted), resp.Msg)
assert.Equal(t, i18n.Msg(i18n.MsgLabelsDeleted), resp.String())
assert.Equal(t, http.StatusOK, r2.Code)
assert.Equal(t, http.StatusOK, resp.Code)
r3 := PerformRequest(app, "GET", "/api/v1/labels?count=15")
val3 := gjson.Get(r3.Body.String(), `#(Name=="BatchDelete").Slug`)
assert.Equal(t, val3.String(), "")
})
t.Run("no labels selected", func(t *testing.T) {
app, router, _ := NewApiTest()
BatchLabelsDelete(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/labels/delete", `{"labels": []}`)
val := gjson.Get(r.Body.String(), "error")
assert.Equal(t, i18n.Msg(i18n.ErrNoLabelsSelected), val.String())
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("InvalidRequest", func(t *testing.T) {
app, router, _ := NewApiTest()
BatchLabelsDelete(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/labels/delete", `{"labels": 123}`)
assert.Equal(t, http.StatusBadRequest, r.Code)
})
}
func TestBatchPhotosApprove(t *testing.T) {
t.Run("Success", func(t *testing.T) {
app, router, _ := NewApiTest()
// Register routes.
GetPhoto(router)
BatchPhotosApprove(router)
r := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0y50")
assert.Equal(t, http.StatusOK, r.Code)
val := gjson.Get(r.Body.String(), "Quality")
assert.Equal(t, "1", val.String())
val4 := gjson.Get(r.Body.String(), "EditedAt")
assert.Empty(t, val4.String())
r2 := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/approve", `{"photos": ["ps6sg6be2lvl0y50", "ps6sg6be2lvl0y90"]}`)
val2 := gjson.Get(r2.Body.String(), "message")
assert.Contains(t, val2.String(), "Selection approved")
assert.Equal(t, http.StatusOK, r2.Code)
r3 := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0y50")
assert.Equal(t, http.StatusOK, r3.Code)
val5 := gjson.Get(r3.Body.String(), "Quality")
assert.Equal(t, "7", val5.String())
val6 := gjson.Get(r3.Body.String(), "EditedAt")
assert.NotEmpty(t, val6.String())
})
t.Run("MissingSelection", func(t *testing.T) {
app, router, _ := NewApiTest()
BatchPhotosApprove(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/approve", `{"photos": []}`)
val := gjson.Get(r.Body.String(), "error")
assert.Equal(t, i18n.Msg(i18n.ErrNoItemsSelected), val.String())
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("InvalidRequest", func(t *testing.T) {
app, router, _ := NewApiTest()
BatchPhotosApprove(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/approve", `{"photos": 123}`)
assert.Equal(t, http.StatusBadRequest, r.Code)
})
}
func TestBatchPhotosDelete(t *testing.T) {
t.Run("ErrNoItemsSelected", func(t *testing.T) {
app, router, _ := NewApiTest()
BatchPhotosDelete(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/delete", `{"photos": []}`)
val := gjson.Get(r.Body.String(), "error")
assert.Equal(t, i18n.Msg(i18n.ErrNoItemsSelected), val.String())
assert.Equal(t, http.StatusBadRequest, r.Code)
})
}

View File

@@ -1168,76 +1168,6 @@
} }
} }
}, },
"/api/v1/batch/photos": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Photos"
],
"summary": "returns the metadata of multiple pictures so that it can be edited",
"operationId": "BatchPhotos",
"parameters": [
{
"description": "Photo Selection",
"name": "photos",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/form.Selection"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/batch.PhotoForm"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"429": {
"description": "Too Many Requests",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
}
}
}
},
"/api/v1/batch/photos/approve": { "/api/v1/batch/photos/approve": {
"post": { "post": {
"consumes": [ "consumes": [
@@ -1430,6 +1360,76 @@
} }
} }
}, },
"/api/v1/batch/photos/edit": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Photos"
],
"summary": "returns the metadata of multiple pictures so that it can be edited",
"operationId": "BatchPhotosEdit",
"parameters": [
{
"description": "photos selection and values",
"name": "photos",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/batch.PhotosRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/batch.PhotosForm"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"429": {
"description": "Too Many Requests",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
}
}
}
},
"/api/v1/batch/photos/private": { "/api/v1/batch/photos/private": {
"post": { "post": {
"consumes": [ "consumes": [
@@ -5170,14 +5170,16 @@
"batch.Action": { "batch.Action": {
"type": "string", "type": "string",
"enum": [ "enum": [
"remove", "none",
"keep", "update",
"change" "add",
"remove"
], ],
"x-enum-varnames": [ "x-enum-varnames": [
"ActionRemove", "ActionNone",
"ActionKeep", "ActionUpdate",
"ActionChange" "ActionAdd",
"ActionRemove"
] ]
}, },
"batch.Bool": { "batch.Bool": {
@@ -5186,7 +5188,7 @@
"action": { "action": {
"$ref": "#/definitions/batch.Action" "$ref": "#/definitions/batch.Action"
}, },
"matches": { "mixed": {
"type": "boolean" "type": "boolean"
}, },
"value": { "value": {
@@ -5200,7 +5202,7 @@
"action": { "action": {
"$ref": "#/definitions/batch.Action" "$ref": "#/definitions/batch.Action"
}, },
"matches": { "mixed": {
"type": "boolean" "type": "boolean"
}, },
"value": { "value": {
@@ -5214,7 +5216,7 @@
"action": { "action": {
"$ref": "#/definitions/batch.Action" "$ref": "#/definitions/batch.Action"
}, },
"matches": { "mixed": {
"type": "boolean" "type": "boolean"
}, },
"value": { "value": {
@@ -5228,7 +5230,7 @@
"action": { "action": {
"$ref": "#/definitions/batch.Action" "$ref": "#/definitions/batch.Action"
}, },
"matches": { "mixed": {
"type": "boolean" "type": "boolean"
}, },
"value": { "value": {
@@ -5236,30 +5238,39 @@
} }
} }
}, },
"batch.PhotoForm": { "batch.PhotosForm": {
"type": "object", "type": "object",
"properties": { "properties": {
"Altitude": { "Altitude": {
"$ref": "#/definitions/batch.Int" "$ref": "#/definitions/batch.Int"
}, },
"Artist": {
"$ref": "#/definitions/batch.String"
},
"CameraID": { "CameraID": {
"$ref": "#/definitions/batch.UInt" "$ref": "#/definitions/batch.UInt"
}, },
"Caption": { "Caption": {
"$ref": "#/definitions/batch.String" "$ref": "#/definitions/batch.String"
}, },
"Copyright": {
"$ref": "#/definitions/batch.String"
},
"Country": { "Country": {
"$ref": "#/definitions/batch.String" "$ref": "#/definitions/batch.String"
}, },
"Day": { "Day": {
"$ref": "#/definitions/batch.Int" "$ref": "#/definitions/batch.Int"
}, },
"DetailsArtist": {
"$ref": "#/definitions/batch.String"
},
"DetailsCopyright": {
"$ref": "#/definitions/batch.String"
},
"DetailsKeywords": {
"$ref": "#/definitions/batch.String"
},
"DetailsLicense": {
"$ref": "#/definitions/batch.String"
},
"DetailsSubject": {
"$ref": "#/definitions/batch.String"
},
"Exposure": { "Exposure": {
"$ref": "#/definitions/batch.String" "$ref": "#/definitions/batch.String"
}, },
@@ -5281,9 +5292,6 @@
"LensID": { "LensID": {
"$ref": "#/definitions/batch.UInt" "$ref": "#/definitions/batch.UInt"
}, },
"License": {
"$ref": "#/definitions/batch.String"
},
"Lng": { "Lng": {
"$ref": "#/definitions/batch.Float64" "$ref": "#/definitions/batch.Float64"
}, },
@@ -5299,9 +5307,6 @@
"Scan": { "Scan": {
"$ref": "#/definitions/batch.Bool" "$ref": "#/definitions/batch.Bool"
}, },
"Subject": {
"$ref": "#/definitions/batch.String"
},
"TakenAt": { "TakenAt": {
"type": "string" "type": "string"
}, },
@@ -5322,13 +5327,33 @@
} }
} }
}, },
"batch.PhotosRequest": {
"type": "object",
"properties": {
"filter": {
"type": "string"
},
"photos": {
"type": "array",
"items": {
"type": "string"
}
},
"return": {
"type": "boolean"
},
"values": {
"$ref": "#/definitions/batch.PhotosForm"
}
}
},
"batch.String": { "batch.String": {
"type": "object", "type": "object",
"properties": { "properties": {
"action": { "action": {
"$ref": "#/definitions/batch.Action" "$ref": "#/definitions/batch.Action"
}, },
"matches": { "mixed": {
"type": "boolean" "type": "boolean"
}, },
"value": { "value": {
@@ -5342,7 +5367,7 @@
"action": { "action": {
"$ref": "#/definitions/batch.Action" "$ref": "#/definitions/batch.Action"
}, },
"matches": { "mixed": {
"type": "boolean" "type": "boolean"
}, },
"value": { "value": {
@@ -7215,6 +7240,9 @@
"type": "string" "type": "string"
} }
}, },
"filter": {
"type": "string"
},
"labels": { "labels": {
"type": "array", "type": "array",
"items": { "items": {
@@ -7699,6 +7727,22 @@
"DeletedAt": { "DeletedAt": {
"type": "string" "type": "string"
}, },
"DetailsArtist": {
"type": "string"
},
"DetailsCopyright": {
"type": "string"
},
"DetailsKeywords": {
"description": "Additional information from the details table.",
"type": "string"
},
"DetailsLicense": {
"type": "string"
},
"DetailsSubject": {
"type": "string"
},
"DocumentID": { "DocumentID": {
"type": "string" "type": "string"
}, },
@@ -7730,6 +7774,7 @@
"type": "string" "type": "string"
}, },
"Files": { "Files": {
"description": "List of files if search results are merged.",
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/definitions/entity.File" "$ref": "#/definitions/entity.File"
@@ -7923,6 +7968,22 @@
"time.Duration": { "time.Duration": {
"type": "integer", "type": "integer",
"enum": [ "enum": [
-9223372036854775808,
9223372036854775807,
1,
1000,
1000000,
1000000000,
60000000000,
3600000000000,
-9223372036854775808,
9223372036854775807,
1,
1000,
1000000,
1000000000,
60000000000,
3600000000000,
-9223372036854775808, -9223372036854775808,
9223372036854775807, 9223372036854775807,
1, 1,
@@ -7933,6 +7994,22 @@
3600000000000 3600000000000
], ],
"x-enum-varnames": [ "x-enum-varnames": [
"minDuration",
"maxDuration",
"Nanosecond",
"Microsecond",
"Millisecond",
"Second",
"Minute",
"Hour",
"minDuration",
"maxDuration",
"Nanosecond",
"Microsecond",
"Millisecond",
"Second",
"Minute",
"Hour",
"minDuration", "minDuration",
"maxDuration", "maxDuration",
"Nanosecond", "Nanosecond",

View File

@@ -75,3 +75,10 @@ var DetailsFixtures = DetailsMap{
LicenseSrc: "manual", LicenseSrc: "manual",
}, },
} }
// CreateDetailsFixtures inserts known entities into the database for testing.
func CreateDetailsFixtures() {
for _, entity := range DetailsFixtures {
UnscopedDb().Create(&entity)
}
}

View File

@@ -32,5 +32,7 @@ func ResetTestFixtures() {
CreateTestFixtures() CreateTestFixtures()
File{}.RegenerateIndex()
log.Debugf("migrate: recreated test fixtures [%s]", time.Since(start)) log.Debugf("migrate: recreated test fixtures [%s]", time.Since(start))
} }

View File

@@ -10,6 +10,7 @@ func CreateTestFixtures() {
CreateCameraFixtures() CreateCameraFixtures()
CreateCountryFixtures() CreateCountryFixtures()
CreatePhotoFixtures() CreatePhotoFixtures()
CreateDetailsFixtures()
CreateAlbumFixtures() CreateAlbumFixtures()
CreateServiceFixtures() CreateServiceFixtures()
CreateLinkFixtures() CreateLinkFixtures()

View File

@@ -0,0 +1,104 @@
package search
import (
"strings"
"time"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/txt"
)
// BatchResult represents a photo geo search result.
type BatchResult struct {
ID uint `json:"-" select:"photos.id"`
CompositeID string `json:"ID,omitempty" select:"files.photo_id AS composite_id"`
UUID string `json:"DocumentID,omitempty" select:"photos.uuid"`
PhotoUID string `json:"UID" select:"photos.photo_uid"`
PhotoType string `json:"Type" select:"photos.photo_type"`
TypeSrc string `json:"TypeSrc" select:"photos.taken_src"`
PhotoTitle string `json:"Title" select:"photos.photo_title"`
PhotoCaption string `json:"Caption,omitempty" select:"photos.photo_caption"`
TakenAt time.Time `json:"TakenAt" select:"photos.taken_at"`
TakenAtLocal time.Time `json:"TakenAtLocal" select:"photos.taken_at_local"`
TimeZone string `json:"TimeZone" select:"photos.time_zone"`
PhotoYear int `json:"Year" select:"photos.photo_year"`
PhotoMonth int `json:"Month" select:"photos.photo_month"`
PhotoDay int `json:"Day" select:"photos.photo_day"`
PhotoCountry string `json:"Country" select:"photos.photo_country"`
PhotoStack int8 `json:"Stack" select:"photos.photo_stack"`
PhotoFavorite bool `json:"Favorite" select:"photos.photo_favorite"`
PhotoPrivate bool `json:"Private" select:"photos.photo_private"`
PhotoIso int `json:"Iso" select:"photos.photo_iso"`
PhotoFocalLength int `json:"FocalLength" select:"photos.photo_focal_length"`
PhotoFNumber float32 `json:"FNumber" select:"photos.photo_f_number"`
PhotoExposure string `json:"Exposure" select:"photos.photo_exposure"`
PhotoFaces int `json:"Faces,omitempty" select:"photos.photo_faces"`
PhotoQuality int `json:"Quality" select:"photos.photo_quality"`
PhotoResolution int `json:"Resolution" select:"photos.photo_resolution"`
PhotoDuration time.Duration `json:"Duration,omitempty" select:"photos.photo_duration"`
PhotoColor int16 `json:"Color" select:"photos.photo_color"`
PhotoScan bool `json:"Scan" select:"photos.photo_scan"`
PhotoPanorama bool `json:"Panorama" select:"photos.photo_panorama"`
CameraID uint `json:"CameraID" select:"photos.camera_id"` // Camera
CameraSrc string `json:"CameraSrc,omitempty" select:"photos.camera_src"`
CameraSerial string `json:"CameraSerial,omitempty" select:"photos.camera_serial"`
CameraMake string `json:"CameraMake,omitempty" select:"cameras.camera_make"`
CameraModel string `json:"CameraModel,omitempty" select:"cameras.camera_model"`
CameraType string `json:"CameraType,omitempty" select:"cameras.camera_type"`
LensID uint `json:"LensID" select:"photos.lens_id"` // Lens
LensMake string `json:"LensMake,omitempty" select:"lenses.lens_model"`
LensModel string `json:"LensModel,omitempty" select:"lenses.lens_make"`
PhotoAltitude int `json:"Altitude,omitempty" select:"photos.photo_altitude"`
PhotoLat float64 `json:"Lat" select:"photos.photo_lat"`
PhotoLng float64 `json:"Lng" select:"photos.photo_lng"`
FileID uint `json:"-" select:"files.id AS file_id"` // File
FileUID string `json:"FileUID" select:"files.file_uid"`
FileRoot string `json:"FileRoot" select:"files.file_root"`
FileName string `json:"FileName" select:"files.file_name"`
OriginalName string `json:"OriginalName" select:"files.original_name"`
FileHash string `json:"Hash" select:"files.file_hash"`
FileWidth int `json:"Width" select:"files.file_width"`
FileHeight int `json:"Height" select:"files.file_height"`
FilePortrait bool `json:"Portrait" select:"files.file_portrait"`
FilePrimary bool `json:"-" select:"files.file_primary"`
FileSidecar bool `json:"-" select:"files.file_sidecar"`
FileMissing bool `json:"-" select:"files.file_missing"`
FileVideo bool `json:"-" select:"files.file_video"`
FileDuration time.Duration `json:"-" select:"files.file_duration"`
FileFPS float64 `json:"-" select:"files.file_fps"`
FileFrames int `json:"-" select:"files.file_frames"`
FilePages int `json:"-" select:"files.file_pages"`
FileCodec string `json:"-" select:"files.file_codec"`
FileType string `json:"-" select:"files.file_type"`
MediaType string `json:"-" select:"files.media_type"`
FileMime string `json:"-" select:"files.file_mime"`
FileSize int64 `json:"-" select:"files.file_size"`
FileOrientation int `json:"-" select:"files.file_orientation"`
FileProjection string `json:"-" select:"files.file_projection"`
FileAspectRatio float32 `json:"-" select:"files.file_aspect_ratio"`
DetailsKeywords string `json:"DetailsKeywords" select:"details.keywords AS details_keywords"`
DetailsSubject string `json:"DetailsSubject" select:"details.subject AS details_subject"`
DetailsArtist string `json:"DetailsArtist" select:"details.artist AS details_artist"`
DetailsCopyright string `json:"DetailsCopyright" select:"details.copyright AS details_copyright"`
DetailsLicense string `json:"DetailsLicense" select:"details.license AS details_license"`
}
// BatchCols contains the result column names necessary for the photo viewer.
var BatchCols = SelectString(BatchResult{}, SelectCols(BatchResult{}, []string{"*"}))
// BatchPhotos finds PhotoResults based on the search form without checking rights or permissions.
func BatchPhotos(uids []string, sess *entity.Session) (results PhotoResults, count int, err error) {
frm := form.SearchPhotos{
UID: strings.Join(uids, txt.Or),
Count: MaxResults,
Offset: 0,
Face: "",
Merged: true,
Details: true,
}
return searchPhotos(frm, sess, BatchCols)
}

View File

@@ -0,0 +1,22 @@
package search
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestBatchPhotos(t *testing.T) {
t.Run("Success", func(t *testing.T) {
uids := []string{"ps6sg6be2lvl0yh7", "ps6sg6be2lvl0yh8"}
photos, count, err := BatchPhotos(uids, nil)
assert.Equal(t, 2, count)
assert.Len(t, photos, 2)
if err != nil {
t.Fatal(err)
}
})
}

View File

@@ -81,8 +81,14 @@ func searchPhotos(frm form.SearchPhotos, sess *entity.Session, resultCols string
// Specify table names and joins. // Specify table names and joins.
s := UnscopedDb().Table(entity.File{}.TableName()).Select(resultCols). s := UnscopedDb().Table(entity.File{}.TableName()).Select(resultCols).
Joins("JOIN photos ON files.photo_id = photos.id AND files.media_id IS NOT NULL"). Joins("JOIN photos ON files.photo_id = photos.id AND files.media_id IS NOT NULL")
Joins("LEFT JOIN cameras ON photos.camera_id = cameras.id").
// Include additional columns from details table?
if frm.Details {
s = s.Joins("JOIN details ON details.photo_id = files.photo_id")
}
s = s.Joins("LEFT JOIN cameras ON photos.camera_id = cameras.id").
Joins("LEFT JOIN lenses ON photos.lens_id = lenses.id"). Joins("LEFT JOIN lenses ON photos.lens_id = lenses.id").
Joins("LEFT JOIN places ON photos.place_id = places.id") Joins("LEFT JOIN places ON photos.place_id = places.id")

View File

@@ -106,7 +106,15 @@ type Photo struct {
CheckedAt time.Time `json:"CheckedAt,omitempty" select:"photos.checked_at"` CheckedAt time.Time `json:"CheckedAt,omitempty" select:"photos.checked_at"`
DeletedAt *time.Time `json:"DeletedAt,omitempty" select:"photos.deleted_at"` DeletedAt *time.Time `json:"DeletedAt,omitempty" select:"photos.deleted_at"`
Files []entity.File `json:"Files"` // Additional information from the details table.
DetailsKeywords string `json:"DetailsKeywords,omitempty" select:"-"`
DetailsSubject string `json:"DetailsSubject,omitempty" select:"-"`
DetailsArtist string `json:"DetailsArtist,omitempty" select:"-"`
DetailsCopyright string `json:"DetailsCopyright,omitempty" select:"-"`
DetailsLicense string `json:"DetailsLicense,omitempty" select:"-"`
// List of files if search results are merged.
Files []entity.File `json:"Files" select:"-"`
} }
// GetID returns the numeric entity ID. // GetID returns the numeric entity ID.

View File

@@ -3,7 +3,8 @@ package batch
type Action = string type Action = string
const ( const (
ActionNone Action = "none"
ActionUpdate Action = "update"
ActionAdd Action = "add"
ActionRemove Action = "remove" ActionRemove Action = "remove"
ActionKeep Action = "keep"
ActionChange Action = "change"
) )

View File

@@ -1,11 +1,11 @@
package batch package batch
import ( import (
"github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/entity/search"
) )
// PhotoForm represents photo batch edit form values. // PhotosForm represents photo batch edit form values.
type PhotoForm struct { type PhotosForm struct {
PhotoType String `json:"Type"` PhotoType String `json:"Type"`
PhotoTitle String `json:"Title"` PhotoTitle String `json:"Title"`
PhotoCaption String `json:"Caption"` PhotoCaption String `json:"Caption"`
@@ -30,207 +30,223 @@ type PhotoForm struct {
CameraID UInt `json:"CameraID"` CameraID UInt `json:"CameraID"`
LensID UInt `json:"LensID"` LensID UInt `json:"LensID"`
DetailsSubject String `json:"Subject"` DetailsKeywords String `json:"DetailsKeywords"`
DetailsArtist String `json:"Artist"` DetailsSubject String `json:"DetailsSubject"`
DetailsCopyright String `json:"Copyright"` DetailsArtist String `json:"DetailsArtist"`
DetailsLicense String `json:"License"` DetailsCopyright String `json:"DetailsCopyright"`
DetailsLicense String `json:"DetailsLicense"`
} }
func NewPhotoForm(photos entity.Photos) *PhotoForm { func NewPhotosForm(photos search.PhotoResults) *PhotosForm {
frm := &PhotoForm{} frm := &PhotosForm{}
for _, photo := range photos { for i, photo := range photos {
if photo.PhotoType != "" && frm.PhotoType.Value == "" { if i == 0 {
frm.PhotoType.Value = photo.PhotoType frm.PhotoType.Value = photo.PhotoType
frm.PhotoType.Matches = true
} else if photo.PhotoType != frm.PhotoType.Value { } else if photo.PhotoType != frm.PhotoType.Value {
frm.PhotoType.Matches = false frm.PhotoType.Mixed = true
frm.PhotoType.Value = ""
} }
if photo.PhotoTitle != "" && frm.PhotoTitle.Value == "" { if i == 0 {
frm.PhotoTitle.Value = photo.PhotoTitle frm.PhotoTitle.Value = photo.PhotoTitle
frm.PhotoTitle.Matches = true
} else if photo.PhotoTitle != frm.PhotoTitle.Value { } else if photo.PhotoTitle != frm.PhotoTitle.Value {
frm.PhotoTitle.Matches = false frm.PhotoTitle.Mixed = true
frm.PhotoTitle.Value = ""
} }
if photo.PhotoCaption != "" && frm.PhotoCaption.Value == "" { if i == 0 {
frm.PhotoCaption.Value = photo.PhotoCaption frm.PhotoCaption.Value = photo.PhotoCaption
frm.PhotoTitle.Matches = true
} else if photo.PhotoCaption != frm.PhotoCaption.Value { } else if photo.PhotoCaption != frm.PhotoCaption.Value {
frm.PhotoCaption.Matches = false frm.PhotoCaption.Mixed = true
frm.PhotoCaption.Value = ""
} }
if !photo.TakenAt.IsZero() && frm.TakenAt.Value.IsZero() { if i == 0 {
frm.TakenAt.Value = photo.TakenAt frm.TakenAt.Value = photo.TakenAt
frm.TakenAt.Matches = true
} else if photo.TakenAt != frm.TakenAt.Value { } else if photo.TakenAt != frm.TakenAt.Value {
frm.TakenAt.Matches = false frm.TakenAt.Mixed = true
} }
if !photo.TakenAtLocal.IsZero() && frm.TakenAtLocal.Value.IsZero() { if i == 0 {
frm.TakenAtLocal.Value = photo.TakenAtLocal frm.TakenAtLocal.Value = photo.TakenAtLocal
frm.TakenAtLocal.Matches = true
} else if photo.TakenAtLocal != frm.TakenAtLocal.Value { } else if photo.TakenAtLocal != frm.TakenAtLocal.Value {
frm.TakenAtLocal.Matches = false frm.TakenAtLocal.Mixed = true
} }
if photo.PhotoDay > 0 && frm.PhotoDay.Value == 0 { if i == 0 {
frm.PhotoDay.Value = photo.PhotoDay frm.PhotoDay.Value = photo.PhotoDay
frm.PhotoDay.Matches = true
} else if photo.PhotoDay != frm.PhotoDay.Value { } else if photo.PhotoDay != frm.PhotoDay.Value {
frm.PhotoDay.Matches = false frm.PhotoDay.Mixed = true
frm.PhotoDay.Value = 0
} }
if photo.PhotoMonth > 0 && frm.PhotoMonth.Value == 0 { if i == 0 {
frm.PhotoMonth.Value = photo.PhotoMonth frm.PhotoMonth.Value = photo.PhotoMonth
frm.PhotoMonth.Matches = true
} else if photo.PhotoMonth != frm.PhotoMonth.Value { } else if photo.PhotoMonth != frm.PhotoMonth.Value {
frm.PhotoMonth.Matches = false frm.PhotoMonth.Mixed = true
frm.PhotoMonth.Value = 0
} }
if photo.PhotoYear > 0 && frm.PhotoYear.Value == 0 { if i == 0 {
frm.PhotoYear.Value = photo.PhotoYear frm.PhotoYear.Value = photo.PhotoYear
frm.PhotoYear.Matches = true
} else if photo.PhotoYear != frm.PhotoYear.Value { } else if photo.PhotoYear != frm.PhotoYear.Value {
frm.PhotoYear.Matches = false frm.PhotoYear.Mixed = true
frm.PhotoYear.Value = 0
} }
if photo.TimeZone != "" && frm.TimeZone.Value == "" { if i == 0 {
frm.TimeZone.Value = photo.TimeZone frm.TimeZone.Value = photo.TimeZone
frm.TimeZone.Matches = true
} else if photo.TimeZone != frm.TimeZone.Value { } else if photo.TimeZone != frm.TimeZone.Value {
frm.TimeZone.Matches = false frm.TimeZone.Mixed = true
frm.TimeZone.Value = "Local"
} }
if photo.PhotoCountry != "" && frm.PhotoCountry.Value == "" { if i == 0 {
frm.PhotoCountry.Value = photo.PhotoCountry frm.PhotoCountry.Value = photo.PhotoCountry
frm.PhotoCountry.Matches = true
} else if photo.PhotoCountry != frm.PhotoCountry.Value { } else if photo.PhotoCountry != frm.PhotoCountry.Value {
frm.PhotoCountry.Matches = false frm.PhotoCountry.Mixed = true
frm.PhotoCountry.Value = "zz"
} }
if photo.PhotoAltitude != 0 && frm.PhotoAltitude.Value == 0 { if i == 0 {
frm.PhotoAltitude.Value = photo.PhotoAltitude frm.PhotoAltitude.Value = photo.PhotoAltitude
frm.PhotoAltitude.Matches = true
} else if photo.PhotoAltitude != frm.PhotoAltitude.Value { } else if photo.PhotoAltitude != frm.PhotoAltitude.Value {
frm.PhotoAltitude.Matches = false frm.PhotoAltitude.Mixed = true
frm.PhotoAltitude.Value = 0
} }
if photo.PhotoLat != 0.0 && frm.PhotoLat.Value == 0.0 { if i == 0 {
frm.PhotoLat.Value = photo.PhotoLat frm.PhotoLat.Value = photo.PhotoLat
frm.PhotoLat.Matches = true
} else if photo.PhotoLat != frm.PhotoLat.Value { } else if photo.PhotoLat != frm.PhotoLat.Value {
frm.PhotoLat.Matches = false frm.PhotoLat.Mixed = true
frm.PhotoLat.Value = 0.0
} }
if photo.PhotoLng != 0.0 && frm.PhotoLng.Value == 0.0 { if i == 0 {
frm.PhotoLng.Value = photo.PhotoLng frm.PhotoLng.Value = photo.PhotoLng
frm.PhotoLng.Matches = true
} else if photo.PhotoLng != frm.PhotoLng.Value { } else if photo.PhotoLng != frm.PhotoLng.Value {
frm.PhotoLng.Matches = false frm.PhotoLng.Mixed = false
frm.PhotoLng.Value = 0.0
} }
if photo.PhotoIso != 0 && frm.PhotoIso.Value == 0 { if i == 0 {
frm.PhotoIso.Value = photo.PhotoIso frm.PhotoIso.Value = photo.PhotoIso
frm.PhotoIso.Matches = true
} else if photo.PhotoIso != frm.PhotoIso.Value { } else if photo.PhotoIso != frm.PhotoIso.Value {
frm.PhotoIso.Matches = false frm.PhotoIso.Mixed = true
frm.PhotoIso.Value = 0
} }
if photo.PhotoFocalLength != 0 && frm.PhotoFocalLength.Value == 0 { if i == 0 {
frm.PhotoFocalLength.Value = photo.PhotoFocalLength frm.PhotoFocalLength.Value = photo.PhotoFocalLength
frm.PhotoFocalLength.Matches = true
} else if photo.PhotoFocalLength != frm.PhotoFocalLength.Value { } else if photo.PhotoFocalLength != frm.PhotoFocalLength.Value {
frm.PhotoFocalLength.Matches = false frm.PhotoFocalLength.Mixed = true
frm.PhotoFocalLength.Value = 0
} }
if photo.PhotoFNumber != 0.0 && frm.PhotoFNumber.Value == 0.0 { if i == 0 {
frm.PhotoFNumber.Value = photo.PhotoFNumber frm.PhotoFNumber.Value = photo.PhotoFNumber
frm.PhotoFNumber.Matches = true
} else if photo.PhotoFNumber != frm.PhotoFNumber.Value { } else if photo.PhotoFNumber != frm.PhotoFNumber.Value {
frm.PhotoFNumber.Matches = false frm.PhotoFNumber.Mixed = true
frm.PhotoFNumber.Value = 0
} }
if photo.PhotoExposure != "" && frm.PhotoExposure.Value == "" { if i == 0 {
frm.PhotoExposure.Value = photo.PhotoExposure frm.PhotoExposure.Value = photo.PhotoExposure
frm.PhotoExposure.Matches = true
} else if photo.PhotoExposure != frm.PhotoExposure.Value { } else if photo.PhotoExposure != frm.PhotoExposure.Value {
frm.PhotoExposure.Matches = false frm.PhotoExposure.Mixed = true
frm.PhotoExposure.Value = ""
} }
if photo.PhotoFavorite && !frm.PhotoFavorite.Value { if i == 0 {
frm.PhotoFavorite.Value = photo.PhotoFavorite frm.PhotoFavorite.Value = photo.PhotoFavorite
frm.PhotoFavorite.Matches = true
} else if photo.PhotoFavorite != frm.PhotoFavorite.Value { } else if photo.PhotoFavorite != frm.PhotoFavorite.Value {
frm.PhotoFavorite.Matches = false frm.PhotoFavorite.Mixed = true
frm.PhotoFavorite.Value = false
} }
if photo.PhotoPrivate && !frm.PhotoPrivate.Value { if i == 0 {
frm.PhotoPrivate.Value = photo.PhotoPrivate frm.PhotoPrivate.Value = photo.PhotoPrivate
frm.PhotoPrivate.Matches = true
} else if photo.PhotoPrivate != frm.PhotoPrivate.Value { } else if photo.PhotoPrivate != frm.PhotoPrivate.Value {
frm.PhotoPrivate.Matches = false frm.PhotoPrivate.Mixed = true
frm.PhotoPrivate.Value = false
} }
if photo.PhotoScan && !frm.PhotoScan.Value { if i == 0 {
frm.PhotoScan.Value = photo.PhotoScan frm.PhotoScan.Value = photo.PhotoScan
frm.PhotoScan.Matches = true
} else if photo.PhotoScan != frm.PhotoScan.Value { } else if photo.PhotoScan != frm.PhotoScan.Value {
frm.PhotoScan.Matches = false frm.PhotoScan.Mixed = true
frm.PhotoScan.Value = false
} }
if photo.PhotoPanorama && !frm.PhotoPanorama.Value { if i == 0 {
frm.PhotoPanorama.Value = photo.PhotoPanorama frm.PhotoPanorama.Value = photo.PhotoPanorama
frm.PhotoPanorama.Matches = true
} else if photo.PhotoPanorama != frm.PhotoPanorama.Value { } else if photo.PhotoPanorama != frm.PhotoPanorama.Value {
frm.PhotoPanorama.Matches = false frm.PhotoPanorama.Mixed = true
frm.PhotoPanorama.Value = false
} }
if photo.CameraID != 0 && frm.CameraID.Value == 0 { if i == 0 {
frm.CameraID.Value = photo.CameraID frm.CameraID.Value = photo.CameraID
frm.CameraID.Matches = true
} else if photo.CameraID != frm.CameraID.Value { } else if photo.CameraID != frm.CameraID.Value {
frm.CameraID.Matches = false frm.CameraID.Mixed = true
frm.CameraID.Value = 1
} }
if photo.LensID != 0 && frm.LensID.Value == 0 { if i == 0 {
frm.LensID.Value = photo.LensID frm.LensID.Value = photo.LensID
frm.LensID.Matches = true
} else if photo.LensID != frm.LensID.Value { } else if photo.LensID != frm.LensID.Value {
frm.LensID.Matches = false frm.LensID.Mixed = true
frm.LensID.Value = 1
} }
if photo.Details != nil { if i == 0 {
if photo.Details.Subject != "" && frm.DetailsSubject.Value == "" { frm.DetailsKeywords.Value = photo.DetailsKeywords
frm.DetailsSubject.Value = photo.Details.Subject } else if photo.DetailsKeywords != frm.DetailsKeywords.Value {
frm.DetailsSubject.Matches = true frm.DetailsKeywords.Mixed = true
} else if photo.Details.Subject != frm.DetailsSubject.Value { frm.DetailsKeywords.Value = ""
frm.DetailsSubject.Matches = false
}
if photo.Details.Artist != "" && frm.DetailsArtist.Value == "" {
frm.DetailsArtist.Value = photo.Details.Artist
frm.DetailsArtist.Matches = true
} else if photo.Details.Artist != frm.DetailsArtist.Value {
frm.DetailsArtist.Matches = false
}
if photo.Details.Copyright != "" && frm.DetailsCopyright.Value == "" {
frm.DetailsCopyright.Value = photo.Details.Copyright
frm.DetailsCopyright.Matches = true
} else if photo.Details.Copyright != frm.DetailsCopyright.Value {
frm.DetailsCopyright.Matches = false
}
if photo.Details.License != "" && frm.DetailsLicense.Value == "" {
frm.DetailsLicense.Value = photo.Details.License
frm.DetailsLicense.Matches = true
} else if photo.Details.License != frm.DetailsLicense.Value {
frm.DetailsLicense.Matches = false
}
} }
if i == 0 {
frm.DetailsSubject.Value = photo.DetailsSubject
} else if photo.DetailsSubject != frm.DetailsSubject.Value {
frm.DetailsSubject.Mixed = true
frm.DetailsSubject.Value = ""
}
if i == 0 {
frm.DetailsArtist.Value = photo.DetailsArtist
} else if photo.DetailsArtist != frm.DetailsArtist.Value {
frm.DetailsArtist.Mixed = true
frm.DetailsArtist.Value = ""
}
if i == 0 {
frm.DetailsCopyright.Value = photo.DetailsCopyright
} else if photo.DetailsCopyright != frm.DetailsCopyright.Value {
frm.DetailsCopyright.Mixed = true
frm.DetailsCopyright.Value = ""
}
if i == 0 {
frm.DetailsLicense.Value = photo.DetailsLicense
} else if photo.DetailsLicense != frm.DetailsLicense.Value {
frm.DetailsLicense.Mixed = true
frm.DetailsLicense.Value = ""
}
}
// Use defaults for the following values if they are empty:
if frm.PhotoCountry.Value == "" {
frm.PhotoCountry.Value = "zz"
}
if frm.CameraID.Value < 1 {
frm.CameraID.Value = 1
}
if frm.LensID.Value < 1 {
frm.LensID.Value = 1
} }
return frm return frm

View File

@@ -0,0 +1,61 @@
package batch
import (
"encoding/json"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/entity/search"
"github.com/photoprism/photoprism/pkg/fs"
)
func TestNewPhotosForm(t *testing.T) {
t.Run("Success", func(t *testing.T) {
var photos search.PhotoResults
dataFile := fs.Abs("./testdata/photos.json")
data, dataErr := os.ReadFile(dataFile)
if dataErr != nil {
t.Fatal(dataErr)
}
jsonErr := json.Unmarshal(data, &photos)
if jsonErr != nil {
t.Fatal(jsonErr)
}
frm := NewPhotosForm(photos)
// Photo metadata.
assert.Equal(t, "", frm.PhotoType.Value)
assert.Equal(t, true, frm.PhotoType.Mixed)
assert.Equal(t, "", frm.PhotoTitle.Value)
assert.Equal(t, true, frm.PhotoTitle.Mixed)
assert.Equal(t, "", frm.PhotoCaption.Value)
assert.Equal(t, true, frm.PhotoCaption.Mixed)
assert.Equal(t, false, frm.PhotoFavorite.Value)
assert.Equal(t, true, frm.PhotoFavorite.Mixed)
assert.Equal(t, false, frm.PhotoPrivate.Value)
assert.Equal(t, false, frm.PhotoPrivate.Mixed)
assert.Equal(t, uint(1000003), frm.CameraID.Value)
assert.Equal(t, false, frm.CameraID.Mixed)
assert.Equal(t, uint(1000000), frm.LensID.Value)
assert.Equal(t, false, frm.LensID.Mixed)
// Additional details.
assert.Equal(t, "", frm.DetailsKeywords.Value)
assert.Equal(t, true, frm.DetailsKeywords.Mixed)
assert.Equal(t, "", frm.DetailsSubject.Value)
assert.Equal(t, true, frm.DetailsSubject.Mixed)
assert.Equal(t, "", frm.DetailsArtist.Value)
assert.Equal(t, true, frm.DetailsArtist.Mixed)
assert.Equal(t, "", frm.DetailsCopyright.Value)
assert.Equal(t, true, frm.DetailsCopyright.Mixed)
assert.Equal(t, "", frm.DetailsLicense.Value)
assert.Equal(t, true, frm.DetailsLicense.Mixed)
})
}

View File

@@ -0,0 +1,31 @@
package batch
import "strings"
// PhotosRequest represents items selected in the user interface.
type PhotosRequest struct {
Return bool `json:"return,omitempty"`
Filter string `json:"filter,omitempty"`
Photos []string `json:"photos"`
Values *PhotosForm `json:"values,omitempty"`
}
// Empty checks if any specific items were selected.
func (f PhotosRequest) Empty() bool {
switch {
case len(f.Photos) > 0:
return false
}
return true
}
// Get returns a string slice with the selected item UIDs.
func (f PhotosRequest) Get() []string {
return f.Photos
}
// String returns a string containing all selected item UIDs.
func (f PhotosRequest) String() string {
return strings.Join(f.Get(), ", ")
}

442
internal/form/batch/testdata/photos.json vendored Normal file
View File

@@ -0,0 +1,442 @@
[
{
"ID": "1000003-1000011",
"UID": "ps6sg6be2lvl0yh0",
"Type": "video",
"TypeSrc": "",
"TakenAt": "1990-04-18T01:00:00Z",
"TakenAtLocal": "1990-04-18T01:00:00Z",
"TakenSrc": "meta",
"TimeZone": "Local",
"Path": "",
"Name": "",
"Title": "",
"Caption": "",
"Year": 1990,
"Month": 4,
"Day": 18,
"Country": "za",
"Stack": 0,
"Favorite": false,
"Private": false,
"Iso": 400,
"FocalLength": 84,
"FNumber": 4.5,
"Exposure": "",
"Faces": 1,
"Quality": 4,
"Resolution": 45,
"Duration": 7200000000000,
"Color": 12,
"Scan": false,
"Panorama": false,
"CameraID": 1000003,
"CameraMake": "Canon",
"CameraModel": "EOS 6D",
"LensID": 1000000,
"LensMake": "Apple",
"LensModel": "F380",
"Altitude": -100,
"Lat": 48.519234,
"Lng": 9.057997,
"CellID": "",
"PlaceID": "",
"PlaceSrc": "",
"PlaceLabel": "",
"PlaceCity": "",
"PlaceState": "",
"PlaceCountry": "",
"InstanceID": "",
"FileUID": "fs6sg6bw15bnlqdw",
"FileRoot": "/",
"FileName": "1990/04/bridge2.jpg",
"OriginalName": "",
"Hash": "pcad9168fa6acc5c5c2965adf6ec465ca42fd818",
"Width": 1200,
"Height": 1600,
"Portrait": true,
"Merged": true,
"CreatedAt": "0001-01-01T00:00:00Z",
"UpdatedAt": "0001-01-01T00:00:00Z",
"EditedAt": "0001-01-01T00:00:00Z",
"CheckedAt": "0001-01-01T00:00:00Z",
"DetailsKeywords": "bridge, nature",
"DetailsSubject": "Bridge",
"DetailsArtist": "Jens Mander",
"DetailsCopyright": "Copyright 2020",
"DetailsLicense": "n/a",
"Files": [
{
"UID": "fs6sg6bw15bnlqdw",
"PhotoUID": "ps6sg6be2lvl0yh0",
"Name": "1990/04/bridge2.jpg",
"Root": "/",
"Hash": "pcad9168fa6acc5c5c2965adf6ec465ca42fd818",
"Size": 921858,
"Primary": true,
"Codec": "jpeg",
"FileType": "jpg",
"MediaType": "image",
"Mime": "image/jpg",
"Portrait": true,
"Width": 1200,
"Height": 1600,
"Orientation": 6,
"AspectRatio": 0.75,
"CreatedAt": "0001-01-01T00:00:00Z",
"UpdatedAt": "0001-01-01T00:00:00Z",
"Markers": [
{
"UID": "ms6sg6b1wowuy777",
"FileUID": "fs6sg6bw15bnlqdw",
"Type": "face",
"Src": "image",
"Name": "",
"Review": false,
"Invalid": false,
"FaceID": "TOSCDXCS4VI3PGIUTCNIQCNI6HSFXQVZ",
"FaceDist": 0.6,
"SubjUID": "",
"SubjSrc": "",
"X": 0.404687,
"Y": 0.249707,
"W": 0.214062,
"H": 0.321219,
"Size": 200,
"Score": 74,
"Thumb": "pcad9168fa6acc5c5c2965ddf6ec465ca42fd818",
"CreatedAt": "2025-05-04T11:49:42.529705909Z"
}
]
},
{
"UID": "fs6sg6bwhhbnlqdn",
"PhotoUID": "ps6sg6be2lvl0yh0",
"Name": "London/bridge3.jpg",
"Root": "/",
"Hash": "pcad9168fa6acc5c5ba965adf6ec465ca42fd818",
"Size": 921851,
"Primary": false,
"Codec": "jpeg",
"FileType": "jpg",
"MediaType": "image",
"Mime": "image/jpg",
"Portrait": true,
"Width": 1200,
"Height": 1600,
"Orientation": 6,
"AspectRatio": 0.75,
"CreatedAt": "0001-01-01T00:00:00Z",
"UpdatedAt": "0001-01-01T00:00:00Z",
"Markers": [
{
"UID": "ms6sg6b1wowu1000",
"FileUID": "fs6sg6bwhhbnlqdn",
"Type": "face",
"Src": "image",
"Name": "Actress A",
"Review": false,
"Invalid": false,
"FaceID": "GMH5NISEEULNJL6RATITOA3TMZXMTMCI",
"FaceDist": 0.4507357278575355,
"SubjUID": "js6sg6b1h1njaaac",
"SubjSrc": "",
"X": 0.464844,
"Y": 0.449531,
"W": 0.434375,
"H": 0.652582,
"Size": 556,
"Score": 155,
"Thumb": "pcad9168fa6acc5c5c2965ddf6ec465ca42fd818-046045043065",
"CreatedAt": "2025-05-04T11:49:42.442163583Z"
}
]
},
{
"UID": "fs6sg6bwhhbnlqdy",
"PhotoUID": "ps6sg6be2lvl0yh0",
"Name": "1990/04/bridge2.mp4",
"Root": "/",
"Hash": "pcad9168fa6acc5c5ba965adf6ec465ca42fd819",
"Size": 921851,
"Primary": false,
"Codec": "avc1",
"FileType": "mp4",
"MediaType": "video",
"Mime": "image/mp4",
"Portrait": true,
"Video": true,
"Duration": 17000000000,
"Width": 1200,
"Height": 1600,
"Orientation": 6,
"AspectRatio": 0.75,
"CreatedAt": "0001-01-01T00:00:00Z",
"UpdatedAt": "0001-01-01T00:00:00Z",
"Markers": []
}
]
},
{
"ID": "1000001-1000001",
"UID": "ps6sg6be2lvl0yh8",
"Type": "raw",
"TypeSrc": "",
"TakenAt": "2006-01-01T02:00:00Z",
"TakenAtLocal": "2006-01-01T02:00:00Z",
"TakenSrc": "meta",
"TimeZone": "Europe/Berlin",
"Path": "",
"Name": "",
"Title": "",
"Caption": "photo caption non-photographic",
"Year": 2790,
"Month": 2,
"Day": 12,
"Country": "de",
"Stack": 0,
"Favorite": true,
"Private": false,
"Iso": 305,
"FocalLength": 28,
"FNumber": 3.5,
"Exposure": "",
"Quality": 3,
"Resolution": 2,
"Color": 3,
"Scan": false,
"Panorama": false,
"CameraID": 1000003,
"CameraMake": "Canon",
"CameraModel": "EOS 6D",
"LensID": 1000000,
"LensMake": "Apple",
"LensModel": "F380",
"Altitude": -10,
"Lat": 48.519234,
"Lng": 9.057997,
"CellID": "",
"PlaceID": "",
"PlaceSrc": "",
"PlaceLabel": "",
"PlaceCity": "",
"PlaceState": "",
"PlaceCountry": "",
"InstanceID": "",
"FileUID": "fs6sg6bw45bn0001",
"FileRoot": "/",
"FileName": "2790/02/Photo01.dng",
"OriginalName": "",
"Hash": "3cad9168fa6acc5c5c2965ddf6ec465ca42fd818",
"Width": 1200,
"Height": 1600,
"Portrait": true,
"Merged": false,
"CreatedAt": "0001-01-01T00:00:00Z",
"UpdatedAt": "0001-01-01T00:00:00Z",
"EditedAt": "0001-01-01T00:00:00Z",
"CheckedAt": "0001-01-01T00:00:00Z",
"DetailsKeywords": "screenshot, info",
"DetailsSubject": "Non Photographic",
"DetailsArtist": "Hans",
"DetailsCopyright": "copy",
"DetailsLicense": "MIT",
"Files": [
{
"UID": "fs6sg6bw45bn0001",
"PhotoUID": "ps6sg6be2lvl0yh8",
"Name": "2790/02/Photo01.dng",
"Root": "/",
"Hash": "3cad9168fa6acc5c5c2965ddf6ec465ca42fd818",
"Size": 661858,
"Primary": false,
"Codec": "jpeg",
"FileType": "raw",
"MediaType": "raw",
"Mime": "image/DNG",
"Portrait": true,
"Width": 1200,
"Height": 1600,
"Orientation": 6,
"AspectRatio": 0.75,
"CreatedAt": "0001-01-01T00:00:00Z",
"UpdatedAt": "0001-01-01T00:00:00Z",
"Markers": []
}
]
},
{
"ID": "1000000-1000000",
"UID": "ps6sg6be2lvl0yh7",
"Type": "image",
"TypeSrc": "",
"TakenAt": "2008-07-01T10:00:00Z",
"TakenAtLocal": "2008-07-01T12:00:00Z",
"TakenSrc": "meta",
"TimeZone": "Europe/Berlin",
"Path": "",
"Name": "",
"Title": "Lake / 2790",
"Caption": "photo caption lake",
"Year": 2790,
"Month": 7,
"Day": 4,
"Country": "zz",
"Stack": 0,
"Favorite": false,
"Private": false,
"Iso": 200,
"FocalLength": 50,
"FNumber": 5,
"Exposure": "1/80",
"Faces": 3,
"Quality": 3,
"Resolution": 2,
"Color": 9,
"Scan": false,
"Panorama": false,
"CameraID": 1000003,
"CameraSrc": "meta",
"CameraMake": "Canon",
"CameraModel": "EOS 6D",
"LensID": 1000000,
"LensMake": "Apple",
"LensModel": "F380",
"Lat": 0,
"Lng": 0,
"CellID": "",
"PlaceID": "",
"PlaceSrc": "",
"PlaceLabel": "",
"PlaceCity": "",
"PlaceState": "",
"PlaceCountry": "",
"InstanceID": "",
"FileUID": "fs6sg6bw45bnlqdw",
"FileRoot": "/",
"FileName": "2790/07/27900704_070228_D6D51B6C.jpg",
"OriginalName": "Vacation/exampleFileNameOriginal.jpg",
"Hash": "2cad9168fa6acc5c5c2965ddf6ec465ca42fd818",
"Width": 3648,
"Height": 2736,
"Portrait": false,
"Merged": false,
"CreatedAt": "0001-01-01T00:00:00Z",
"UpdatedAt": "0001-01-01T00:00:00Z",
"EditedAt": "0001-01-01T00:00:00Z",
"CheckedAt": "0001-01-01T00:00:00Z",
"DetailsKeywords": "nature, frog",
"DetailsSubject": "Lake",
"DetailsArtist": "Hans",
"DetailsCopyright": "copy",
"DetailsLicense": "MIT",
"Files": [
{
"UID": "fs6sg6bw45bnlqdw",
"PhotoUID": "ps6sg6be2lvl0yh7",
"Name": "2790/07/27900704_070228_D6D51B6C.jpg",
"Root": "/",
"Hash": "2cad9168fa6acc5c5c2965ddf6ec465ca42fd818",
"Size": 4278906,
"Primary": true,
"OriginalName": "Vacation/exampleFileNameOriginal.jpg",
"Codec": "jpeg",
"FileType": "jpg",
"MediaType": "image",
"Mime": "image/jpg",
"Width": 3648,
"Height": 2736,
"Projection": "equirectangular",
"AspectRatio": 1.33333,
"CreatedAt": "0001-01-01T00:00:00Z",
"UpdatedAt": "0001-01-01T00:00:00Z",
"Markers": [
{
"UID": "ms6sg6b14ahkyd24",
"FileUID": "fs6sg6bw45bnlqdw",
"Type": "face",
"Src": "image",
"Name": "",
"Review": false,
"Invalid": false,
"FaceID": "VF7ANLDET2BKZNT4VQWJMMC6HBEFDOG6",
"FaceDist": 0.3139983399779298,
"SubjUID": "",
"SubjSrc": "",
"X": 0.1,
"Y": 0.229688,
"W": 0.246334,
"H": 0.29707,
"Size": 209,
"Score": 55,
"Thumb": "acad9168fa6acc5c5c2965ddf6ec465ca42fd818",
"CreatedAt": "2025-05-04T11:49:42.485807442Z"
},
{
"UID": "ms6sg6b1wowu1005",
"FileUID": "fs6sg6bw45bnlqdw",
"Type": "face",
"Src": "image",
"Name": "Actor A",
"Review": false,
"Invalid": false,
"FaceID": "PI6A2XGOTUXEFI7CBF4KCI5I2I3JEJHS",
"FaceDist": 0.3139983399779298,
"SubjUID": "js6sg6b1h1njaaad",
"SubjSrc": "",
"X": 0.5,
"Y": 0.429688,
"W": 0.746334,
"H": 0.49707,
"Size": 509,
"Score": 100,
"Thumb": "pcad9168fa6acc5c5c2965ddf6ec465ca42fd818",
"CreatedAt": "2025-05-04T11:49:42.626934696Z"
},
{
"UID": "ms6sg6b1wowuy888",
"FileUID": "fs6sg6bw45bnlqdw",
"Type": "face",
"Src": "image",
"Name": "",
"Review": false,
"Invalid": false,
"FaceID": "TOSCDXCS4VI3PGIUTCNIQCNI6HSFXQVZ",
"FaceDist": 0.6,
"SubjUID": "",
"SubjSrc": "",
"X": 0.528125,
"Y": 0.240328,
"W": 0.3625,
"H": 0.543962,
"Size": 200,
"Score": 56,
"Thumb": "pcad9168fa6acc5c5c2965ddf6ec465ca42fd818",
"CreatedAt": "2025-05-04T11:49:42.427572061Z"
},
{
"UID": "ms6sg6b1wowu1001",
"FileUID": "fs6sg6bw45bnlqdw",
"Type": "face",
"Src": "image",
"Name": "Actress A",
"Review": false,
"Invalid": false,
"FaceID": "GMH5NISEEULNJL6RATITOA3TMZXMTMCI",
"FaceDist": 0.5099754448545762,
"SubjUID": "js6sg6b1h1njaaac",
"SubjSrc": "",
"X": 0.547656,
"Y": 0.330986,
"W": 0.402344,
"H": 0.60446,
"Size": 515,
"Score": 102,
"Thumb": "pcad9168fa6acc5c5c2965ddf6ec465ca42fd818-05403304060446",
"CreatedAt": "2025-05-04T11:49:42.457213555Z"
}
]
}
]
}
]

View File

@@ -6,49 +6,49 @@ import (
// String represents batch edit form value. // String represents batch edit form value.
type String struct { type String struct {
Value string `json:"value"` Value string `json:"value"`
Matches bool `json:"matches"` Mixed bool `json:"mixed"`
Action Action `json:"action"` Action Action `json:"action"`
} }
// Bool represents batch edit form value. // Bool represents batch edit form value.
type Bool struct { type Bool struct {
Value bool `json:"value"` Value bool `json:"value"`
Matches bool `json:"matches"` Mixed bool `json:"mixed"`
Action Action `json:"action"` Action Action `json:"action"`
} }
// Time represents batch edit form value. // Time represents batch edit form value.
type Time struct { type Time struct {
Value time.Time `json:"value"` Value time.Time `json:"value"`
Matches bool `json:"matches"` Mixed bool `json:"mixed"`
Action Action `json:"action"` Action Action `json:"action"`
} }
// Int represents batch edit form value. // Int represents batch edit form value.
type Int struct { type Int struct {
Value int `json:"value"` Value int `json:"value"`
Matches bool `json:"matches"` Mixed bool `json:"mixed"`
Action Action `json:"action"` Action Action `json:"action"`
} }
// UInt represents batch edit form value. // UInt represents batch edit form value.
type UInt struct { type UInt struct {
Value uint `json:"value"` Value uint `json:"value"`
Matches bool `json:"matches"` Mixed bool `json:"mixed"`
Action Action `json:"action"` Action Action `json:"action"`
} }
// Float32 represents batch edit form value. // Float32 represents batch edit form value.
type Float32 struct { type Float32 struct {
Value float32 `json:"value"` Value float32 `json:"value"`
Matches bool `json:"matches"` Mixed bool `json:"mixed"`
Action Action `json:"action"` Action Action `json:"action"`
} }
// Float64 represents batch edit form value. // Float64 represents batch edit form value.
type Float64 struct { type Float64 struct {
Value float64 `json:"value"` Value float64 `json:"value"`
Matches bool `json:"matches"` Mixed bool `json:"mixed"`
Action Action `json:"action"` Action Action `json:"action"`
} }

View File

@@ -99,6 +99,7 @@ type SearchPhotos struct {
Offset int `form:"offset" serialize:"-"` // Result FILE offset Offset int `form:"offset" serialize:"-"` // Result FILE offset
Order string `form:"order" serialize:"-"` // Sort order Order string `form:"order" serialize:"-"` // Sort order
Merged bool `form:"merged" serialize:"-"` // Merge FILES in response Merged bool `form:"merged" serialize:"-"` // Merge FILES in response
Details bool `form:"-" serialize:"-"` // Include additional information from details table
} }
func (f *SearchPhotos) GetQuery() string { func (f *SearchPhotos) GetQuery() string {

View File

@@ -5,6 +5,7 @@ import "strings"
// Selection represents items selected in the user interface. // Selection represents items selected in the user interface.
type Selection struct { type Selection struct {
All bool `json:"all"` All bool `json:"all"`
Filter string `json:"filter"`
Files []string `json:"files"` Files []string `json:"files"`
Photos []string `json:"photos"` Photos []string `json:"photos"`
Albums []string `json:"albums"` Albums []string `json:"albums"`

View File

@@ -182,14 +182,14 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.UpdateFace(APIv1) api.UpdateFace(APIv1)
// Batch Operations. // Batch Operations.
api.BatchPhotos(APIv1) api.BatchAlbumsDelete(APIv1)
api.BatchLabelsDelete(APIv1)
api.BatchPhotosEdit(APIv1)
api.BatchPhotosApprove(APIv1) api.BatchPhotosApprove(APIv1)
api.BatchPhotosArchive(APIv1) api.BatchPhotosArchive(APIv1)
api.BatchPhotosRestore(APIv1) api.BatchPhotosRestore(APIv1)
api.BatchPhotosPrivate(APIv1) api.BatchPhotosPrivate(APIv1)
api.BatchPhotosDelete(APIv1) api.BatchPhotosDelete(APIv1)
api.BatchAlbumsDelete(APIv1)
api.BatchLabelsDelete(APIv1)
// Technical Endpoints. // Technical Endpoints.
api.GetSvg(APIv1) api.GetSvg(APIv1)