mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-12 00:34:13 +01: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: "",
|
||||
Width: "",
|
||||
Height: "",
|
||||
// Details.
|
||||
DetailsKeywords: "",
|
||||
DetailsSubject: "",
|
||||
DetailsArtist: "",
|
||||
DetailsCopyright: "",
|
||||
DetailsLicense: "",
|
||||
// Date fields.
|
||||
CreatedAt: "",
|
||||
UpdatedAt: "",
|
||||
|
||||
@@ -39,7 +39,6 @@ func TestMain(m *testing.M) {
|
||||
event.AuditLog = log
|
||||
|
||||
// Init test config.
|
||||
config.Develop = true
|
||||
c := config.TestConfig()
|
||||
get.SetConfig(c)
|
||||
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 (
|
||||
"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/form/batch"
|
||||
"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"
|
||||
)
|
||||
|
||||
// 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
|
||||
// @Id BatchPhotos
|
||||
// @Summary moves multiple pictures to the archive
|
||||
// @Id BatchPhotosArchive
|
||||
// @Tags Photos
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} batch.PhotoForm
|
||||
// @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 [post]
|
||||
func BatchPhotos(router *gin.RouterGroup) {
|
||||
router.POST("/batch/photos", func(c *gin.Context) {
|
||||
s := Auth(c, acl.ResourcePhotos, acl.ActionUpdate)
|
||||
// @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
|
||||
}
|
||||
|
||||
conf := get.Config()
|
||||
|
||||
if !conf.Develop() && !conf.Experimental() {
|
||||
AbortNotImplemented(c)
|
||||
return
|
||||
}
|
||||
|
||||
var frm form.Selection
|
||||
|
||||
// Assign and validate request form values.
|
||||
@@ -53,28 +54,339 @@ func BatchPhotos(router *gin.RouterGroup) {
|
||||
return
|
||||
}
|
||||
|
||||
// Find selected photos.
|
||||
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 {
|
||||
log.Errorf("batch: %s", clean.Error(err))
|
||||
AbortUnexpectedError(c)
|
||||
AbortEntityNotFound(c)
|
||||
return
|
||||
}
|
||||
|
||||
// Load files and details.
|
||||
for _, photo := range photos {
|
||||
photo.PreloadFiles()
|
||||
photo.GetDetails()
|
||||
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)
|
||||
}
|
||||
|
||||
batchFrm := batch.NewPhotoForm(photos)
|
||||
// Update precalculated photo and file counts.
|
||||
entity.UpdateCountsAsync()
|
||||
|
||||
data := gin.H{
|
||||
"photos": photos,
|
||||
"values": batchFrm,
|
||||
}
|
||||
// Update album, subject, and label cover thumbs.
|
||||
query.UpdateCoversAsync()
|
||||
|
||||
c.JSON(http.StatusOK, data)
|
||||
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))
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -11,42 +12,266 @@ import (
|
||||
"github.com/photoprism/photoprism/pkg/i18n"
|
||||
)
|
||||
|
||||
func TestBatchPhotos(t *testing.T) {
|
||||
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())
|
||||
|
||||
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,
|
||||
"POST", "/api/v1/batch/photos",
|
||||
`{"photos": ["ps6sg6be2lvl0yh7", "ps6sg6be2lvl0yh8", "ps6sg6be2lvl0ycc"]}`,
|
||||
)
|
||||
|
||||
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)
|
||||
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()
|
||||
BatchPhotos(router)
|
||||
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos", `{"photos": []}`)
|
||||
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()
|
||||
BatchPhotos(router)
|
||||
r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos", `{"photos": 123}`)
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
"post": {
|
||||
"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": {
|
||||
"post": {
|
||||
"consumes": [
|
||||
@@ -5170,14 +5170,16 @@
|
||||
"batch.Action": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"remove",
|
||||
"keep",
|
||||
"change"
|
||||
"none",
|
||||
"update",
|
||||
"add",
|
||||
"remove"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"ActionRemove",
|
||||
"ActionKeep",
|
||||
"ActionChange"
|
||||
"ActionNone",
|
||||
"ActionUpdate",
|
||||
"ActionAdd",
|
||||
"ActionRemove"
|
||||
]
|
||||
},
|
||||
"batch.Bool": {
|
||||
@@ -5186,7 +5188,7 @@
|
||||
"action": {
|
||||
"$ref": "#/definitions/batch.Action"
|
||||
},
|
||||
"matches": {
|
||||
"mixed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"value": {
|
||||
@@ -5200,7 +5202,7 @@
|
||||
"action": {
|
||||
"$ref": "#/definitions/batch.Action"
|
||||
},
|
||||
"matches": {
|
||||
"mixed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"value": {
|
||||
@@ -5214,7 +5216,7 @@
|
||||
"action": {
|
||||
"$ref": "#/definitions/batch.Action"
|
||||
},
|
||||
"matches": {
|
||||
"mixed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"value": {
|
||||
@@ -5228,7 +5230,7 @@
|
||||
"action": {
|
||||
"$ref": "#/definitions/batch.Action"
|
||||
},
|
||||
"matches": {
|
||||
"mixed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"value": {
|
||||
@@ -5236,30 +5238,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"batch.PhotoForm": {
|
||||
"batch.PhotosForm": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Altitude": {
|
||||
"$ref": "#/definitions/batch.Int"
|
||||
},
|
||||
"Artist": {
|
||||
"$ref": "#/definitions/batch.String"
|
||||
},
|
||||
"CameraID": {
|
||||
"$ref": "#/definitions/batch.UInt"
|
||||
},
|
||||
"Caption": {
|
||||
"$ref": "#/definitions/batch.String"
|
||||
},
|
||||
"Copyright": {
|
||||
"$ref": "#/definitions/batch.String"
|
||||
},
|
||||
"Country": {
|
||||
"$ref": "#/definitions/batch.String"
|
||||
},
|
||||
"Day": {
|
||||
"$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": {
|
||||
"$ref": "#/definitions/batch.String"
|
||||
},
|
||||
@@ -5281,9 +5292,6 @@
|
||||
"LensID": {
|
||||
"$ref": "#/definitions/batch.UInt"
|
||||
},
|
||||
"License": {
|
||||
"$ref": "#/definitions/batch.String"
|
||||
},
|
||||
"Lng": {
|
||||
"$ref": "#/definitions/batch.Float64"
|
||||
},
|
||||
@@ -5299,9 +5307,6 @@
|
||||
"Scan": {
|
||||
"$ref": "#/definitions/batch.Bool"
|
||||
},
|
||||
"Subject": {
|
||||
"$ref": "#/definitions/batch.String"
|
||||
},
|
||||
"TakenAt": {
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"$ref": "#/definitions/batch.Action"
|
||||
},
|
||||
"matches": {
|
||||
"mixed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"value": {
|
||||
@@ -5342,7 +5367,7 @@
|
||||
"action": {
|
||||
"$ref": "#/definitions/batch.Action"
|
||||
},
|
||||
"matches": {
|
||||
"mixed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"value": {
|
||||
@@ -7215,6 +7240,9 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
"type": "string"
|
||||
},
|
||||
"labels": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -7699,6 +7727,22 @@
|
||||
"DeletedAt": {
|
||||
"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": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -7730,6 +7774,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"Files": {
|
||||
"description": "List of files if search results are merged.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/entity.File"
|
||||
@@ -7923,6 +7968,22 @@
|
||||
"time.Duration": {
|
||||
"type": "integer",
|
||||
"enum": [
|
||||
-9223372036854775808,
|
||||
9223372036854775807,
|
||||
1,
|
||||
1000,
|
||||
1000000,
|
||||
1000000000,
|
||||
60000000000,
|
||||
3600000000000,
|
||||
-9223372036854775808,
|
||||
9223372036854775807,
|
||||
1,
|
||||
1000,
|
||||
1000000,
|
||||
1000000000,
|
||||
60000000000,
|
||||
3600000000000,
|
||||
-9223372036854775808,
|
||||
9223372036854775807,
|
||||
1,
|
||||
@@ -7933,6 +7994,22 @@
|
||||
3600000000000
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"minDuration",
|
||||
"maxDuration",
|
||||
"Nanosecond",
|
||||
"Microsecond",
|
||||
"Millisecond",
|
||||
"Second",
|
||||
"Minute",
|
||||
"Hour",
|
||||
"minDuration",
|
||||
"maxDuration",
|
||||
"Nanosecond",
|
||||
"Microsecond",
|
||||
"Millisecond",
|
||||
"Second",
|
||||
"Minute",
|
||||
"Hour",
|
||||
"minDuration",
|
||||
"maxDuration",
|
||||
"Nanosecond",
|
||||
|
||||
@@ -75,3 +75,10 @@ var DetailsFixtures = DetailsMap{
|
||||
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()
|
||||
|
||||
File{}.RegenerateIndex()
|
||||
|
||||
log.Debugf("migrate: recreated test fixtures [%s]", time.Since(start))
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ func CreateTestFixtures() {
|
||||
CreateCameraFixtures()
|
||||
CreateCountryFixtures()
|
||||
CreatePhotoFixtures()
|
||||
CreateDetailsFixtures()
|
||||
CreateAlbumFixtures()
|
||||
CreateServiceFixtures()
|
||||
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.
|
||||
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("LEFT JOIN cameras ON photos.camera_id = cameras.id").
|
||||
Joins("JOIN photos ON files.photo_id = photos.id AND files.media_id IS NOT NULL")
|
||||
|
||||
// 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 places ON photos.place_id = places.id")
|
||||
|
||||
|
||||
@@ -106,7 +106,15 @@ type Photo struct {
|
||||
CheckedAt time.Time `json:"CheckedAt,omitempty" select:"photos.checked_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.
|
||||
|
||||
@@ -3,7 +3,8 @@ package batch
|
||||
type Action = string
|
||||
|
||||
const (
|
||||
ActionNone Action = "none"
|
||||
ActionUpdate Action = "update"
|
||||
ActionAdd Action = "add"
|
||||
ActionRemove Action = "remove"
|
||||
ActionKeep Action = "keep"
|
||||
ActionChange Action = "change"
|
||||
)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package batch
|
||||
|
||||
import (
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/entity/search"
|
||||
)
|
||||
|
||||
// PhotoForm represents photo batch edit form values.
|
||||
type PhotoForm struct {
|
||||
// PhotosForm represents photo batch edit form values.
|
||||
type PhotosForm struct {
|
||||
PhotoType String `json:"Type"`
|
||||
PhotoTitle String `json:"Title"`
|
||||
PhotoCaption String `json:"Caption"`
|
||||
@@ -30,207 +30,223 @@ type PhotoForm struct {
|
||||
CameraID UInt `json:"CameraID"`
|
||||
LensID UInt `json:"LensID"`
|
||||
|
||||
DetailsSubject String `json:"Subject"`
|
||||
DetailsArtist String `json:"Artist"`
|
||||
DetailsCopyright String `json:"Copyright"`
|
||||
DetailsLicense String `json:"License"`
|
||||
DetailsKeywords String `json:"DetailsKeywords"`
|
||||
DetailsSubject String `json:"DetailsSubject"`
|
||||
DetailsArtist String `json:"DetailsArtist"`
|
||||
DetailsCopyright String `json:"DetailsCopyright"`
|
||||
DetailsLicense String `json:"DetailsLicense"`
|
||||
}
|
||||
|
||||
func NewPhotoForm(photos entity.Photos) *PhotoForm {
|
||||
frm := &PhotoForm{}
|
||||
func NewPhotosForm(photos search.PhotoResults) *PhotosForm {
|
||||
frm := &PhotosForm{}
|
||||
|
||||
for _, photo := range photos {
|
||||
if photo.PhotoType != "" && frm.PhotoType.Value == "" {
|
||||
for i, photo := range photos {
|
||||
if i == 0 {
|
||||
frm.PhotoType.Value = photo.PhotoType
|
||||
frm.PhotoType.Matches = true
|
||||
} 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.Matches = true
|
||||
} 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.PhotoTitle.Matches = true
|
||||
} 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.Matches = true
|
||||
} 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.Matches = true
|
||||
} 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.Matches = true
|
||||
} 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.Matches = true
|
||||
} 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.Matches = true
|
||||
} 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.Matches = true
|
||||
} 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.Matches = true
|
||||
} 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.Matches = true
|
||||
} 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.Matches = true
|
||||
} 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.Matches = true
|
||||
} 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.Matches = true
|
||||
} 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.Matches = true
|
||||
} 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.Matches = true
|
||||
} 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.Matches = true
|
||||
} 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.Matches = true
|
||||
} 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.Matches = true
|
||||
} 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.Matches = true
|
||||
} 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.Matches = true
|
||||
} 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.Matches = true
|
||||
} 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.Matches = true
|
||||
} else if photo.LensID != frm.LensID.Value {
|
||||
frm.LensID.Matches = false
|
||||
frm.LensID.Mixed = true
|
||||
frm.LensID.Value = 1
|
||||
}
|
||||
|
||||
if photo.Details != nil {
|
||||
if photo.Details.Subject != "" && frm.DetailsSubject.Value == "" {
|
||||
frm.DetailsSubject.Value = photo.Details.Subject
|
||||
frm.DetailsSubject.Matches = true
|
||||
} else if photo.Details.Subject != frm.DetailsSubject.Value {
|
||||
frm.DetailsSubject.Matches = false
|
||||
if i == 0 {
|
||||
frm.DetailsKeywords.Value = photo.DetailsKeywords
|
||||
} else if photo.DetailsKeywords != frm.DetailsKeywords.Value {
|
||||
frm.DetailsKeywords.Mixed = true
|
||||
frm.DetailsKeywords.Value = ""
|
||||
}
|
||||
|
||||
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 i == 0 {
|
||||
frm.DetailsSubject.Value = photo.DetailsSubject
|
||||
} else if photo.DetailsSubject != frm.DetailsSubject.Value {
|
||||
frm.DetailsSubject.Mixed = true
|
||||
frm.DetailsSubject.Value = ""
|
||||
}
|
||||
|
||||
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 i == 0 {
|
||||
frm.DetailsArtist.Value = photo.DetailsArtist
|
||||
} else if photo.DetailsArtist != frm.DetailsArtist.Value {
|
||||
frm.DetailsArtist.Mixed = true
|
||||
frm.DetailsArtist.Value = ""
|
||||
}
|
||||
|
||||
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.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
|
||||
|
||||
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -7,48 +7,48 @@ import (
|
||||
// String represents batch edit form value.
|
||||
type String struct {
|
||||
Value string `json:"value"`
|
||||
Matches bool `json:"matches"`
|
||||
Mixed bool `json:"mixed"`
|
||||
Action Action `json:"action"`
|
||||
}
|
||||
|
||||
// Bool represents batch edit form value.
|
||||
type Bool struct {
|
||||
Value bool `json:"value"`
|
||||
Matches bool `json:"matches"`
|
||||
Mixed bool `json:"mixed"`
|
||||
Action Action `json:"action"`
|
||||
}
|
||||
|
||||
// Time represents batch edit form value.
|
||||
type Time struct {
|
||||
Value time.Time `json:"value"`
|
||||
Matches bool `json:"matches"`
|
||||
Mixed bool `json:"mixed"`
|
||||
Action Action `json:"action"`
|
||||
}
|
||||
|
||||
// Int represents batch edit form value.
|
||||
type Int struct {
|
||||
Value int `json:"value"`
|
||||
Matches bool `json:"matches"`
|
||||
Mixed bool `json:"mixed"`
|
||||
Action Action `json:"action"`
|
||||
}
|
||||
|
||||
// UInt represents batch edit form value.
|
||||
type UInt struct {
|
||||
Value uint `json:"value"`
|
||||
Matches bool `json:"matches"`
|
||||
Mixed bool `json:"mixed"`
|
||||
Action Action `json:"action"`
|
||||
}
|
||||
|
||||
// Float32 represents batch edit form value.
|
||||
type Float32 struct {
|
||||
Value float32 `json:"value"`
|
||||
Matches bool `json:"matches"`
|
||||
Mixed bool `json:"mixed"`
|
||||
Action Action `json:"action"`
|
||||
}
|
||||
|
||||
// Float64 represents batch edit form value.
|
||||
type Float64 struct {
|
||||
Value float64 `json:"value"`
|
||||
Matches bool `json:"matches"`
|
||||
Mixed bool `json:"mixed"`
|
||||
Action Action `json:"action"`
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ type SearchPhotos struct {
|
||||
Offset int `form:"offset" serialize:"-"` // Result FILE offset
|
||||
Order string `form:"order" serialize:"-"` // Sort order
|
||||
Merged bool `form:"merged" serialize:"-"` // Merge FILES in response
|
||||
Details bool `form:"-" serialize:"-"` // Include additional information from details table
|
||||
}
|
||||
|
||||
func (f *SearchPhotos) GetQuery() string {
|
||||
|
||||
@@ -5,6 +5,7 @@ import "strings"
|
||||
// Selection represents items selected in the user interface.
|
||||
type Selection struct {
|
||||
All bool `json:"all"`
|
||||
Filter string `json:"filter"`
|
||||
Files []string `json:"files"`
|
||||
Photos []string `json:"photos"`
|
||||
Albums []string `json:"albums"`
|
||||
|
||||
@@ -182,14 +182,14 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
|
||||
api.UpdateFace(APIv1)
|
||||
|
||||
// Batch Operations.
|
||||
api.BatchPhotos(APIv1)
|
||||
api.BatchAlbumsDelete(APIv1)
|
||||
api.BatchLabelsDelete(APIv1)
|
||||
api.BatchPhotosEdit(APIv1)
|
||||
api.BatchPhotosApprove(APIv1)
|
||||
api.BatchPhotosArchive(APIv1)
|
||||
api.BatchPhotosRestore(APIv1)
|
||||
api.BatchPhotosPrivate(APIv1)
|
||||
api.BatchPhotosDelete(APIv1)
|
||||
api.BatchAlbumsDelete(APIv1)
|
||||
api.BatchLabelsDelete(APIv1)
|
||||
|
||||
// Technical Endpoints.
|
||||
api.GetSvg(APIv1)
|
||||
|
||||
Reference in New Issue
Block a user