mirror of
https://github.com/photoprism/photoprism.git
synced 2025-10-29 19:32:25 +08:00
API: Rename /batch/photos endpoint to /batch/photos/edit #271
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
@@ -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: "",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
88
internal/api/batch_albums.go
Normal file
88
internal/api/batch_albums.go
Normal 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))
|
||||||
|
})
|
||||||
|
}
|
||||||
67
internal/api/batch_labels.go
Normal file
67
internal/api/batch_labels.go
Normal 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))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
94
internal/api/batch_photos_edit.go
Normal file
94
internal/api/batch_photos_edit.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
79
internal/api/batch_photos_edit_test.go
Normal file
79
internal/api/batch_photos_edit_test.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ func CreateTestFixtures() {
|
|||||||
CreateCameraFixtures()
|
CreateCameraFixtures()
|
||||||
CreateCountryFixtures()
|
CreateCountryFixtures()
|
||||||
CreatePhotoFixtures()
|
CreatePhotoFixtures()
|
||||||
|
CreateDetailsFixtures()
|
||||||
CreateAlbumFixtures()
|
CreateAlbumFixtures()
|
||||||
CreateServiceFixtures()
|
CreateServiceFixtures()
|
||||||
CreateLinkFixtures()
|
CreateLinkFixtures()
|
||||||
|
|||||||
104
internal/entity/search/batch.go
Normal file
104
internal/entity/search/batch.go
Normal 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)
|
||||||
|
}
|
||||||
22
internal/entity/search/batch_test.go
Normal file
22
internal/entity/search/batch_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
61
internal/form/batch/photos_test.go
Normal file
61
internal/form/batch/photos_test.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
31
internal/form/batch/selection.go
Normal file
31
internal/form/batch/selection.go
Normal 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
442
internal/form/batch/testdata/photos.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user