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

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

View File

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

View File

@@ -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()

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,94 @@
package api
import (
"net/http"
"github.com/dustin/go-humanize/english"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/auth/acl"
"github.com/photoprism/photoprism/internal/entity/search"
"github.com/photoprism/photoprism/internal/form/batch"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/i18n"
)
// BatchPhotosEdit returns the metadata of multiple pictures so that it can be edited.
//
// @Summary returns the metadata of multiple pictures so that it can be edited
// @Id BatchPhotosEdit
// @Tags Photos
// @Accept json
// @Produce json
// @Success 200 {object} batch.PhotosForm
// @Failure 400,401,403,404,429,500 {object} i18n.Response
// @Param photos body batch.PhotosRequest true "photos selection and values"
// @Router /api/v1/batch/photos/edit [post]
func BatchPhotosEdit(router *gin.RouterGroup) {
router.POST("/batch/photos/edit", func(c *gin.Context) {
s := Auth(c, acl.ResourcePhotos, acl.ActionUpdate)
if s.Abort(c) {
return
}
conf := get.Config()
if !conf.Develop() && !conf.Experimental() {
AbortNotImplemented(c)
return
}
var frm batch.PhotosRequest
// Assign and validate request form values.
if err := c.BindJSON(&frm); err != nil {
AbortBadRequest(c)
return
}
if len(frm.Photos) == 0 {
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
return
}
// Fetch selected photos from database.
photos, count, err := search.BatchPhotos(frm.Photos, s)
log.Debugf("batch: %s selected for editing", english.Plural(count, "photo", "photos"))
// Abort if no photos were found.
if err != nil {
log.Errorf("batch: %s", clean.Error(err))
AbortUnexpectedError(c)
return
}
// TODO: Implement photo metadata update based on submitted form values.
if frm.Values != nil {
log.Debugf("batch: updating photo metadata %#v (not yet implemented)", frm.Values)
for _, photo := range photos {
log.Debugf("batch: updating metadata of photo %s (not yet implemented)", photo.PhotoUID)
}
}
// Create batch edit form values form from photo metadata.
batchFrm := batch.NewPhotosForm(photos)
var data gin.H
if frm.Return {
data = gin.H{
"photos": photos,
"values": batchFrm,
}
} else {
data = gin.H{
"values": batchFrm,
}
}
c.JSON(http.StatusOK, data)
})
}

View File

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

View File

@@ -1,8 +1,9 @@
package api
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)
})
}

View File

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

View File

@@ -1168,76 +1168,6 @@
}
}
},
"/api/v1/batch/photos": {
"post": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Photos"
],
"summary": "returns the metadata of multiple pictures so that it can be edited",
"operationId": "BatchPhotos",
"parameters": [
{
"description": "Photo Selection",
"name": "photos",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/form.Selection"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/batch.PhotoForm"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"429": {
"description": "Too Many Requests",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
}
}
}
},
"/api/v1/batch/photos/approve": {
"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",

View File

@@ -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)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -81,8 +81,14 @@ func searchPhotos(frm form.SearchPhotos, sess *entity.Session, resultCols string
// Specify table names and joins.
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")

View File

@@ -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.

View File

@@ -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"
)

View File

@@ -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 photo.Details.Artist != "" && frm.DetailsArtist.Value == "" {
frm.DetailsArtist.Value = photo.Details.Artist
frm.DetailsArtist.Matches = true
} else if photo.Details.Artist != frm.DetailsArtist.Value {
frm.DetailsArtist.Matches = false
}
if photo.Details.Copyright != "" && frm.DetailsCopyright.Value == "" {
frm.DetailsCopyright.Value = photo.Details.Copyright
frm.DetailsCopyright.Matches = true
} else if photo.Details.Copyright != frm.DetailsCopyright.Value {
frm.DetailsCopyright.Matches = false
}
if photo.Details.License != "" && frm.DetailsLicense.Value == "" {
frm.DetailsLicense.Value = photo.Details.License
frm.DetailsLicense.Matches = true
} else if photo.Details.License != frm.DetailsLicense.Value {
frm.DetailsLicense.Matches = false
}
if i == 0 {
frm.DetailsKeywords.Value = photo.DetailsKeywords
} else if photo.DetailsKeywords != frm.DetailsKeywords.Value {
frm.DetailsKeywords.Mixed = true
frm.DetailsKeywords.Value = ""
}
if i == 0 {
frm.DetailsSubject.Value = photo.DetailsSubject
} else if photo.DetailsSubject != frm.DetailsSubject.Value {
frm.DetailsSubject.Mixed = true
frm.DetailsSubject.Value = ""
}
if i == 0 {
frm.DetailsArtist.Value = photo.DetailsArtist
} else if photo.DetailsArtist != frm.DetailsArtist.Value {
frm.DetailsArtist.Mixed = true
frm.DetailsArtist.Value = ""
}
if i == 0 {
frm.DetailsCopyright.Value = photo.DetailsCopyright
} else if photo.DetailsCopyright != frm.DetailsCopyright.Value {
frm.DetailsCopyright.Mixed = true
frm.DetailsCopyright.Value = ""
}
if i == 0 {
frm.DetailsLicense.Value = photo.DetailsLicense
} else if photo.DetailsLicense != frm.DetailsLicense.Value {
frm.DetailsLicense.Mixed = true
frm.DetailsLicense.Value = ""
}
}
// Use defaults for the following values if they are empty:
if frm.PhotoCountry.Value == "" {
frm.PhotoCountry.Value = "zz"
}
if frm.CameraID.Value < 1 {
frm.CameraID.Value = 1
}
if frm.LensID.Value < 1 {
frm.LensID.Value = 1
}
return frm

View File

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

View File

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

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

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

View File

@@ -6,49 +6,49 @@ import (
// String represents batch edit form value.
type String struct {
Value string `json:"value"`
Matches bool `json:"matches"`
Action Action `json:"action"`
Value string `json:"value"`
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"`
Action Action `json:"action"`
Value bool `json:"value"`
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"`
Action Action `json:"action"`
Value time.Time `json:"value"`
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"`
Action Action `json:"action"`
Value int `json:"value"`
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"`
Action Action `json:"action"`
Value uint `json:"value"`
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"`
Action Action `json:"action"`
Value float32 `json:"value"`
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"`
Action Action `json:"action"`
Value float64 `json:"value"`
Mixed bool `json:"mixed"`
Action Action `json:"action"`
}

View File

@@ -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 {

View File

@@ -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"`

View File

@@ -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)