mirror of
				https://github.com/photoprism/photoprism.git
				synced 2025-10-31 04:06:43 +08:00 
			
		
		
		
	API: Rename /batch/photos endpoint to /batch/photos/edit #271
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
		| @@ -144,6 +144,12 @@ export class Photo extends RestModel { | |||||||
|       Hash: "", |       Hash: "", | ||||||
|       Width: "", |       Width: "", | ||||||
|       Height: "", |       Height: "", | ||||||
|  |       // Details. | ||||||
|  |       DetailsKeywords: "", | ||||||
|  |       DetailsSubject: "", | ||||||
|  |       DetailsArtist: "", | ||||||
|  |       DetailsCopyright: "", | ||||||
|  |       DetailsLicense: "", | ||||||
|       // Date fields. |       // Date fields. | ||||||
|       CreatedAt: "", |       CreatedAt: "", | ||||||
|       UpdatedAt: "", |       UpdatedAt: "", | ||||||
|   | |||||||
| @@ -39,7 +39,6 @@ func TestMain(m *testing.M) { | |||||||
| 	event.AuditLog = log | 	event.AuditLog = log | ||||||
|  |  | ||||||
| 	// Init test config. | 	// Init test config. | ||||||
| 	config.Develop = true |  | ||||||
| 	c := config.TestConfig() | 	c := config.TestConfig() | ||||||
| 	get.SetConfig(c) | 	get.SetConfig(c) | ||||||
| 	defer c.CloseDb() | 	defer c.CloseDb() | ||||||
|   | |||||||
| @@ -1,519 +0,0 @@ | |||||||
| package api |  | ||||||
|  |  | ||||||
| import ( |  | ||||||
| 	"net/http" |  | ||||||
| 	"path" |  | ||||||
| 	"time" |  | ||||||
|  |  | ||||||
| 	"github.com/dustin/go-humanize/english" |  | ||||||
|  |  | ||||||
| 	"github.com/gin-gonic/gin" |  | ||||||
| 	"github.com/jinzhu/gorm" |  | ||||||
|  |  | ||||||
| 	"github.com/photoprism/photoprism/internal/auth/acl" |  | ||||||
| 	"github.com/photoprism/photoprism/internal/config" |  | ||||||
| 	"github.com/photoprism/photoprism/internal/entity" |  | ||||||
| 	"github.com/photoprism/photoprism/internal/entity/query" |  | ||||||
| 	"github.com/photoprism/photoprism/internal/event" |  | ||||||
| 	"github.com/photoprism/photoprism/internal/form" |  | ||||||
| 	"github.com/photoprism/photoprism/internal/photoprism" |  | ||||||
| 	"github.com/photoprism/photoprism/internal/photoprism/get" |  | ||||||
| 	"github.com/photoprism/photoprism/pkg/clean" |  | ||||||
| 	"github.com/photoprism/photoprism/pkg/i18n" |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| // BatchPhotosArchive moves multiple pictures to the archive. |  | ||||||
| // |  | ||||||
| //	@Summary	moves multiple pictures to the archive |  | ||||||
| //	@Id			BatchPhotosArchive |  | ||||||
| //	@Tags		Photos |  | ||||||
| //	@Accept		json |  | ||||||
| //	@Produce	json |  | ||||||
| //	@Success	200						{object}	i18n.Response |  | ||||||
| //	@Failure	400,401,403,404,429,500	{object}	i18n.Response |  | ||||||
| //	@Param		photos					body		form.Selection	true	"Photo Selection" |  | ||||||
| //	@Router		/api/v1/batch/photos/archive [post] |  | ||||||
| func BatchPhotosArchive(router *gin.RouterGroup) { |  | ||||||
| 	router.POST("/batch/photos/archive", func(c *gin.Context) { |  | ||||||
| 		s := Auth(c, acl.ResourcePhotos, acl.ActionDelete) |  | ||||||
|  |  | ||||||
| 		if s.Abort(c) { |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		var frm form.Selection |  | ||||||
|  |  | ||||||
| 		// Assign and validate request form values. |  | ||||||
| 		if err := c.BindJSON(&frm); err != nil { |  | ||||||
| 			AbortBadRequest(c) |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		if len(frm.Photos) == 0 { |  | ||||||
| 			Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected) |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		log.Infof("photos: archiving %s", clean.Log(frm.String())) |  | ||||||
|  |  | ||||||
| 		if get.Config().SidecarYaml() { |  | ||||||
| 			// Fetch selection from index. |  | ||||||
| 			photos, err := query.SelectedPhotos(frm) |  | ||||||
|  |  | ||||||
| 			if err != nil { |  | ||||||
| 				AbortEntityNotFound(c) |  | ||||||
| 				return |  | ||||||
| 			} |  | ||||||
|  |  | ||||||
| 			for _, p := range photos { |  | ||||||
| 				if archiveErr := p.Archive(); archiveErr != nil { |  | ||||||
| 					log.Errorf("archive: %s", archiveErr) |  | ||||||
| 				} else { |  | ||||||
| 					SaveSidecarYaml(p) |  | ||||||
| 				} |  | ||||||
| 			} |  | ||||||
| 		} else if err := entity.Db().Where("photo_uid IN (?)", frm.Photos).Delete(&entity.Photo{}).Error; err != nil { |  | ||||||
| 			log.Errorf("archive: failed to archive %d pictures (%s)", len(frm.Photos), err) |  | ||||||
| 			AbortSaveFailed(c) |  | ||||||
| 			return |  | ||||||
| 		} else if err = entity.Db().Model(&entity.PhotoAlbum{}).Where("photo_uid IN (?)", frm.Photos).UpdateColumn("hidden", true).Error; err != nil { |  | ||||||
| 			log.Errorf("archive: failed to flag %d pictures as hidden (%s)", len(frm.Photos), err) |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		// Update precalculated photo and file counts. |  | ||||||
| 		entity.UpdateCountsAsync() |  | ||||||
|  |  | ||||||
| 		// Update album, subject, and label cover thumbs. |  | ||||||
| 		query.UpdateCoversAsync() |  | ||||||
|  |  | ||||||
| 		UpdateClientConfig() |  | ||||||
|  |  | ||||||
| 		event.EntitiesArchived("photos", frm.Photos) |  | ||||||
|  |  | ||||||
| 		c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgSelectionArchived)) |  | ||||||
| 	}) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // BatchPhotosRestore restores multiple pictures from the archive. |  | ||||||
| // |  | ||||||
| //	@Summary	restores multiple pictures from the archive |  | ||||||
| //	@Id			BatchPhotosRestore |  | ||||||
| //	@Tags		Photos |  | ||||||
| //	@Accept		json |  | ||||||
| //	@Produce	json |  | ||||||
| //	@Success	200						{object}	i18n.Response |  | ||||||
| //	@Failure	400,401,403,404,429,500	{object}	i18n.Response |  | ||||||
| //	@Param		photos					body		form.Selection	true	"Photo Selection" |  | ||||||
| //	@Router		/api/v1/batch/photos/restore [post] |  | ||||||
| func BatchPhotosRestore(router *gin.RouterGroup) { |  | ||||||
| 	router.POST("/batch/photos/restore", func(c *gin.Context) { |  | ||||||
| 		s := Auth(c, acl.ResourcePhotos, acl.ActionDelete) |  | ||||||
|  |  | ||||||
| 		if s.Abort(c) { |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		var frm form.Selection |  | ||||||
|  |  | ||||||
| 		if err := c.BindJSON(&frm); err != nil { |  | ||||||
| 			AbortBadRequest(c) |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		if len(frm.Photos) == 0 { |  | ||||||
| 			Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected) |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		log.Infof("photos: restoring %s", clean.Log(frm.String())) |  | ||||||
|  |  | ||||||
| 		if get.Config().SidecarYaml() { |  | ||||||
| 			// Fetch selection from index. |  | ||||||
| 			photos, err := query.SelectedPhotos(frm) |  | ||||||
|  |  | ||||||
| 			if err != nil { |  | ||||||
| 				AbortEntityNotFound(c) |  | ||||||
| 				return |  | ||||||
| 			} |  | ||||||
|  |  | ||||||
| 			for _, p := range photos { |  | ||||||
| 				if err = p.Restore(); err != nil { |  | ||||||
| 					log.Errorf("restore: %s", err) |  | ||||||
| 				} else { |  | ||||||
| 					SaveSidecarYaml(p) |  | ||||||
| 				} |  | ||||||
| 			} |  | ||||||
| 		} else if err := entity.Db().Unscoped().Model(&entity.Photo{}).Where("photo_uid IN (?)", frm.Photos). |  | ||||||
| 			UpdateColumn("deleted_at", gorm.Expr("NULL")).Error; err != nil { |  | ||||||
| 			log.Errorf("restore: %s", err) |  | ||||||
| 			AbortSaveFailed(c) |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		// Update precalculated photo and file counts. |  | ||||||
| 		entity.UpdateCountsAsync() |  | ||||||
|  |  | ||||||
| 		// Update album, subject, and label cover thumbs. |  | ||||||
| 		query.UpdateCoversAsync() |  | ||||||
|  |  | ||||||
| 		UpdateClientConfig() |  | ||||||
|  |  | ||||||
| 		event.EntitiesRestored("photos", frm.Photos) |  | ||||||
|  |  | ||||||
| 		c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgSelectionRestored)) |  | ||||||
| 	}) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // BatchPhotosApprove approves multiple pictures that are currently under review. |  | ||||||
| // |  | ||||||
| //	@Summary	approves multiple pictures that are currently under review |  | ||||||
| //	@Id			BatchPhotosApprove |  | ||||||
| //	@Tags		Photos |  | ||||||
| //	@Accept		json |  | ||||||
| //	@Produce	json |  | ||||||
| //	@Success	200					{object}	i18n.Response |  | ||||||
| //	@Failure	400,401,403,404,429	{object}	i18n.Response |  | ||||||
| //	@Param		photos				body		form.Selection	true	"Photo Selection" |  | ||||||
| //	@Router		/api/v1/batch/photos/approve [post] |  | ||||||
| func BatchPhotosApprove(router *gin.RouterGroup) { |  | ||||||
| 	router.POST("batch/photos/approve", func(c *gin.Context) { |  | ||||||
| 		s := Auth(c, acl.ResourcePhotos, acl.ActionUpdate) |  | ||||||
|  |  | ||||||
| 		if s.Abort(c) { |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		var frm form.Selection |  | ||||||
|  |  | ||||||
| 		if err := c.BindJSON(&frm); err != nil { |  | ||||||
| 			AbortBadRequest(c) |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		if len(frm.Photos) == 0 { |  | ||||||
| 			Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected) |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		log.Infof("photos: approving %s", clean.Log(frm.String())) |  | ||||||
|  |  | ||||||
| 		// Fetch selection from index. |  | ||||||
| 		photos, err := query.SelectedPhotos(frm) |  | ||||||
|  |  | ||||||
| 		if err != nil { |  | ||||||
| 			AbortEntityNotFound(c) |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		var approved entity.Photos |  | ||||||
|  |  | ||||||
| 		for _, p := range photos { |  | ||||||
| 			if err = p.Approve(); err != nil { |  | ||||||
| 				log.Errorf("approve: %s", err) |  | ||||||
| 			} else { |  | ||||||
| 				approved = append(approved, p) |  | ||||||
| 				SaveSidecarYaml(p) |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		UpdateClientConfig() |  | ||||||
|  |  | ||||||
| 		event.EntitiesUpdated("photos", approved) |  | ||||||
|  |  | ||||||
| 		c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgSelectionApproved)) |  | ||||||
| 	}) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // BatchAlbumsDelete permanently removes multiple albums. |  | ||||||
| // |  | ||||||
| //	@Summary	permanently removes multiple albums |  | ||||||
| //	@Id			BatchAlbumsDelete |  | ||||||
| //	@Tags		Albums |  | ||||||
| //	@Accept		json |  | ||||||
| //	@Produce	json |  | ||||||
| //	@Success	200					{object}	i18n.Response |  | ||||||
| //	@Failure	400,401,403,404,429	{object}	i18n.Response |  | ||||||
| //	@Param		albums				body		form.Selection	true	"Album Selection" |  | ||||||
| //	@Router		/api/v1/batch/albums/delete [post] |  | ||||||
| func BatchAlbumsDelete(router *gin.RouterGroup) { |  | ||||||
| 	router.POST("/batch/albums/delete", func(c *gin.Context) { |  | ||||||
| 		s := Auth(c, acl.ResourceAlbums, acl.ActionDelete) |  | ||||||
|  |  | ||||||
| 		if s.Abort(c) { |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		var frm form.Selection |  | ||||||
|  |  | ||||||
| 		if err := c.BindJSON(&frm); err != nil { |  | ||||||
| 			AbortBadRequest(c) |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		// Get album UIDs. |  | ||||||
| 		albumUIDs := frm.Albums |  | ||||||
|  |  | ||||||
| 		if len(albumUIDs) == 0 { |  | ||||||
| 			Abort(c, http.StatusBadRequest, i18n.ErrNoAlbumsSelected) |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		log.Infof("albums: deleting %s", clean.Log(frm.String())) |  | ||||||
|  |  | ||||||
| 		// Fetch albums. |  | ||||||
| 		albums, queryErr := query.AlbumsByUID(albumUIDs, false) |  | ||||||
|  |  | ||||||
| 		if queryErr != nil { |  | ||||||
| 			log.Errorf("albums: %s (find)", queryErr) |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		// Abort if no albums with a matching UID were found. |  | ||||||
| 		if len(albums) == 0 { |  | ||||||
| 			AbortEntityNotFound(c) |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		deleted := 0 |  | ||||||
| 		conf := get.Config() |  | ||||||
|  |  | ||||||
| 		// Flag matching albums as deleted. |  | ||||||
| 		for _, a := range albums { |  | ||||||
| 			if deleteErr := a.Delete(); deleteErr != nil { |  | ||||||
| 				log.Errorf("albums: %s (delete)", deleteErr) |  | ||||||
| 			} else { |  | ||||||
| 				if conf.BackupAlbums() { |  | ||||||
| 					SaveAlbumYaml(a) |  | ||||||
| 				} |  | ||||||
|  |  | ||||||
| 				deleted++ |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		// Update client config if at least one album was successfully deleted. |  | ||||||
| 		if deleted > 0 { |  | ||||||
| 			UpdateClientConfig() |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgAlbumsDeleted)) |  | ||||||
| 	}) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // BatchPhotosPrivate toggles private state of multiple pictures. |  | ||||||
| // |  | ||||||
| //	@Summary	toggles private state of multiple pictures |  | ||||||
| //	@Id			BatchPhotosPrivate |  | ||||||
| //	@Tags		Photos |  | ||||||
| //	@Accept		json |  | ||||||
| //	@Produce	json |  | ||||||
| //	@Success	200						{object}	i18n.Response |  | ||||||
| //	@Failure	400,401,403,404,429,500	{object}	i18n.Response |  | ||||||
| //	@Param		photos					body		form.Selection	true	"Photo Selection" |  | ||||||
| //	@Router		/api/v1/batch/photos/private [post] |  | ||||||
| func BatchPhotosPrivate(router *gin.RouterGroup) { |  | ||||||
| 	router.POST("/batch/photos/private", func(c *gin.Context) { |  | ||||||
| 		s := Auth(c, acl.ResourcePhotos, acl.AccessPrivate) |  | ||||||
|  |  | ||||||
| 		if s.Abort(c) { |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		var frm form.Selection |  | ||||||
|  |  | ||||||
| 		if err := c.BindJSON(&frm); err != nil { |  | ||||||
| 			AbortBadRequest(c) |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		if len(frm.Photos) == 0 { |  | ||||||
| 			Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected) |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		log.Infof("photos: updating private flag for %s", clean.Log(frm.String())) |  | ||||||
|  |  | ||||||
| 		if err := entity.Db().Model(entity.Photo{}).Where("photo_uid IN (?)", frm.Photos).UpdateColumn("photo_private", |  | ||||||
| 			gorm.Expr("CASE WHEN photo_private > 0 THEN 0 ELSE 1 END")).Error; err != nil { |  | ||||||
| 			log.Errorf("private: %s", err) |  | ||||||
| 			AbortSaveFailed(c) |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		// Update precalculated photo and file counts. |  | ||||||
| 		entity.UpdateCountsAsync() |  | ||||||
|  |  | ||||||
| 		// Fetch selection from index. |  | ||||||
| 		if photos, err := query.SelectedPhotos(frm); err == nil { |  | ||||||
| 			for _, p := range photos { |  | ||||||
| 				SaveSidecarYaml(p) |  | ||||||
| 			} |  | ||||||
|  |  | ||||||
| 			event.EntitiesUpdated("photos", photos) |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		UpdateClientConfig() |  | ||||||
|  |  | ||||||
| 		FlushCoverCache() |  | ||||||
|  |  | ||||||
| 		c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgSelectionProtected)) |  | ||||||
| 	}) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // BatchLabelsDelete deletes multiple labels. |  | ||||||
| // |  | ||||||
| //	@Summary	deletes multiple labels |  | ||||||
| //	@Id			BatchLabelsDelete |  | ||||||
| //	@Tags		Labels |  | ||||||
| //	@Accept		json |  | ||||||
| //	@Produce	json |  | ||||||
| //	@Success	200					{object}	i18n.Response |  | ||||||
| //	@Failure	400,401,403,429,500	{object}	i18n.Response |  | ||||||
| //	@Param		labels				body		form.Selection	true	"Label Selection" |  | ||||||
| //	@Router		/api/v1/batch/labels/delete [post] |  | ||||||
| func BatchLabelsDelete(router *gin.RouterGroup) { |  | ||||||
| 	router.POST("/batch/labels/delete", func(c *gin.Context) { |  | ||||||
| 		s := Auth(c, acl.ResourceLabels, acl.ActionDelete) |  | ||||||
|  |  | ||||||
| 		if s.Abort(c) { |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		var frm form.Selection |  | ||||||
|  |  | ||||||
| 		if err := c.BindJSON(&frm); err != nil { |  | ||||||
| 			AbortBadRequest(c) |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		if len(frm.Labels) == 0 { |  | ||||||
| 			log.Error("no labels selected") |  | ||||||
| 			Abort(c, http.StatusBadRequest, i18n.ErrNoLabelsSelected) |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		log.Infof("labels: deleting %s", clean.Log(frm.String())) |  | ||||||
|  |  | ||||||
| 		var labels entity.Labels |  | ||||||
|  |  | ||||||
| 		if err := entity.Db().Where("label_uid IN (?)", frm.Labels).Find(&labels).Error; err != nil { |  | ||||||
| 			Error(c, http.StatusInternalServerError, err, i18n.ErrDeleteFailed) |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		for _, label := range labels { |  | ||||||
| 			logErr("labels", label.Delete()) |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		UpdateClientConfig() |  | ||||||
|  |  | ||||||
| 		event.EntitiesDeleted("labels", frm.Labels) |  | ||||||
|  |  | ||||||
| 		c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgLabelsDeleted)) |  | ||||||
| 	}) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // BatchPhotosDelete permanently removes multiple pictures from the archive. |  | ||||||
| // |  | ||||||
| //	@Summary	permanently removes multiple or all photos from the archive |  | ||||||
| //	@Id			BatchPhotosDelete |  | ||||||
| //	@Tags		Photos |  | ||||||
| //	@Accept		json |  | ||||||
| //	@Produce	json |  | ||||||
| //	@Success	200				{object}	i18n.Response |  | ||||||
| //	@Failure	400,401,403,429	{object}	i18n.Response |  | ||||||
| //	@Param		photos			body		form.Selection	true	"All or Photo Selection" |  | ||||||
| //	@Router		/api/v1/batch/photos/delete [post] |  | ||||||
| func BatchPhotosDelete(router *gin.RouterGroup) { |  | ||||||
| 	router.POST("/batch/photos/delete", func(c *gin.Context) { |  | ||||||
| 		s := Auth(c, acl.ResourcePhotos, acl.ActionDelete) |  | ||||||
|  |  | ||||||
| 		if s.Abort(c) { |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		conf := get.Config() |  | ||||||
|  |  | ||||||
| 		if conf.ReadOnly() || !conf.Settings().Features.Delete { |  | ||||||
| 			AbortFeatureDisabled(c) |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		var frm form.Selection |  | ||||||
|  |  | ||||||
| 		if err := c.BindJSON(&frm); err != nil { |  | ||||||
| 			AbortBadRequest(c) |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		deleteStart := time.Now() |  | ||||||
|  |  | ||||||
| 		var photos entity.Photos |  | ||||||
| 		var err error |  | ||||||
|  |  | ||||||
| 		// Abort if user wants to delete all but does not have sufficient privileges. |  | ||||||
| 		if frm.All && !acl.Rules.AllowAll(acl.ResourcePhotos, s.UserRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) { |  | ||||||
| 			AbortForbidden(c) |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		// Get selection or all archived photos if f.All is true. |  | ||||||
| 		if len(frm.Photos) == 0 && !frm.All { |  | ||||||
| 			Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected) |  | ||||||
| 			return |  | ||||||
| 		} else if frm.All { |  | ||||||
| 			photos, err = query.ArchivedPhotos(1000000, 0) |  | ||||||
| 		} else { |  | ||||||
| 			photos, err = query.SelectedPhotos(frm) |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		// Abort if the query failed or no photos were found. |  | ||||||
| 		if err != nil { |  | ||||||
| 			log.Errorf("archive: %s", err) |  | ||||||
| 			Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected) |  | ||||||
| 			return |  | ||||||
| 		} else if len(photos) > 0 { |  | ||||||
| 			log.Infof("archive: deleting %s", english.Plural(len(photos), "photo", "photos")) |  | ||||||
| 		} else { |  | ||||||
| 			Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected) |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		var deleted entity.Photos |  | ||||||
|  |  | ||||||
| 		var numFiles = 0 |  | ||||||
|  |  | ||||||
| 		// Delete photos. |  | ||||||
| 		for _, p := range photos { |  | ||||||
| 			// Report file deletion. |  | ||||||
| 			event.AuditWarn([]string{ClientIP(c), s.UserName, "delete", path.Join(p.PhotoPath, p.PhotoName+"*")}) |  | ||||||
|  |  | ||||||
| 			// Remove all related files from storage. |  | ||||||
| 			n, deleteErr := photoprism.DeletePhoto(p, true, true) |  | ||||||
|  |  | ||||||
| 			numFiles += n |  | ||||||
|  |  | ||||||
| 			if deleteErr != nil { |  | ||||||
| 				log.Errorf("delete: %s", deleteErr) |  | ||||||
| 			} else { |  | ||||||
| 				deleted = append(deleted, p) |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		if numFiles > 0 || len(deleted) > 0 { |  | ||||||
| 			log.Infof("archive: deleted %s and %s [%s]", english.Plural(numFiles, "file", "files"), english.Plural(len(deleted), "photo", "photos"), time.Since(deleteStart)) |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		// Any photos deleted? |  | ||||||
| 		if len(deleted) > 0 { |  | ||||||
| 			config.FlushUsageCache() |  | ||||||
|  |  | ||||||
| 			// Update precalculated photo and file counts. |  | ||||||
| 			entity.UpdateCountsAsync() |  | ||||||
|  |  | ||||||
| 			UpdateClientConfig() |  | ||||||
|  |  | ||||||
| 			event.EntitiesDeleted("photos", deleted.UIDs()) |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgPermanentlyDeleted)) |  | ||||||
| 	}) |  | ||||||
| } |  | ||||||
							
								
								
									
										88
									
								
								internal/api/batch_albums.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										88
									
								
								internal/api/batch_albums.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,88 @@ | |||||||
|  | package api | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"net/http" | ||||||
|  |  | ||||||
|  | 	"github.com/gin-gonic/gin" | ||||||
|  |  | ||||||
|  | 	"github.com/photoprism/photoprism/internal/auth/acl" | ||||||
|  | 	"github.com/photoprism/photoprism/internal/entity/query" | ||||||
|  | 	"github.com/photoprism/photoprism/internal/form" | ||||||
|  | 	"github.com/photoprism/photoprism/internal/photoprism/get" | ||||||
|  | 	"github.com/photoprism/photoprism/pkg/clean" | ||||||
|  | 	"github.com/photoprism/photoprism/pkg/i18n" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | // BatchAlbumsDelete permanently removes multiple albums. | ||||||
|  | // | ||||||
|  | //	@Summary	permanently removes multiple albums | ||||||
|  | //	@Id			BatchAlbumsDelete | ||||||
|  | //	@Tags		Albums | ||||||
|  | //	@Accept		json | ||||||
|  | //	@Produce	json | ||||||
|  | //	@Success	200					{object}	i18n.Response | ||||||
|  | //	@Failure	400,401,403,404,429	{object}	i18n.Response | ||||||
|  | //	@Param		albums				body		form.Selection	true	"Album Selection" | ||||||
|  | //	@Router		/api/v1/batch/albums/delete [post] | ||||||
|  | func BatchAlbumsDelete(router *gin.RouterGroup) { | ||||||
|  | 	router.POST("/batch/albums/delete", func(c *gin.Context) { | ||||||
|  | 		s := Auth(c, acl.ResourceAlbums, acl.ActionDelete) | ||||||
|  |  | ||||||
|  | 		if s.Abort(c) { | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		var frm form.Selection | ||||||
|  |  | ||||||
|  | 		if err := c.BindJSON(&frm); err != nil { | ||||||
|  | 			AbortBadRequest(c) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// Get album UIDs. | ||||||
|  | 		albumUIDs := frm.Albums | ||||||
|  |  | ||||||
|  | 		if len(albumUIDs) == 0 { | ||||||
|  | 			Abort(c, http.StatusBadRequest, i18n.ErrNoAlbumsSelected) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		log.Infof("albums: deleting %s", clean.Log(frm.String())) | ||||||
|  |  | ||||||
|  | 		// Fetch albums. | ||||||
|  | 		albums, queryErr := query.AlbumsByUID(albumUIDs, false) | ||||||
|  |  | ||||||
|  | 		if queryErr != nil { | ||||||
|  | 			log.Errorf("albums: %s (find)", queryErr) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// Abort if no albums with a matching UID were found. | ||||||
|  | 		if len(albums) == 0 { | ||||||
|  | 			AbortEntityNotFound(c) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		deleted := 0 | ||||||
|  | 		conf := get.Config() | ||||||
|  |  | ||||||
|  | 		// Flag matching albums as deleted. | ||||||
|  | 		for _, a := range albums { | ||||||
|  | 			if deleteErr := a.Delete(); deleteErr != nil { | ||||||
|  | 				log.Errorf("albums: %s (delete)", deleteErr) | ||||||
|  | 			} else { | ||||||
|  | 				if conf.BackupAlbums() { | ||||||
|  | 					SaveAlbumYaml(a) | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				deleted++ | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// Update client config if at least one album was successfully deleted. | ||||||
|  | 		if deleted > 0 { | ||||||
|  | 			UpdateClientConfig() | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgAlbumsDeleted)) | ||||||
|  | 	}) | ||||||
|  | } | ||||||
							
								
								
									
										67
									
								
								internal/api/batch_labels.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								internal/api/batch_labels.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,67 @@ | |||||||
|  | package api | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"net/http" | ||||||
|  |  | ||||||
|  | 	"github.com/gin-gonic/gin" | ||||||
|  |  | ||||||
|  | 	"github.com/photoprism/photoprism/internal/auth/acl" | ||||||
|  | 	"github.com/photoprism/photoprism/internal/entity" | ||||||
|  | 	"github.com/photoprism/photoprism/internal/event" | ||||||
|  | 	"github.com/photoprism/photoprism/internal/form" | ||||||
|  | 	"github.com/photoprism/photoprism/pkg/clean" | ||||||
|  | 	"github.com/photoprism/photoprism/pkg/i18n" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | // BatchLabelsDelete deletes multiple labels. | ||||||
|  | // | ||||||
|  | //	@Summary	deletes multiple labels | ||||||
|  | //	@Id			BatchLabelsDelete | ||||||
|  | //	@Tags		Labels | ||||||
|  | //	@Accept		json | ||||||
|  | //	@Produce	json | ||||||
|  | //	@Success	200					{object}	i18n.Response | ||||||
|  | //	@Failure	400,401,403,429,500	{object}	i18n.Response | ||||||
|  | //	@Param		labels				body		form.Selection	true	"Label Selection" | ||||||
|  | //	@Router		/api/v1/batch/labels/delete [post] | ||||||
|  | func BatchLabelsDelete(router *gin.RouterGroup) { | ||||||
|  | 	router.POST("/batch/labels/delete", func(c *gin.Context) { | ||||||
|  | 		s := Auth(c, acl.ResourceLabels, acl.ActionDelete) | ||||||
|  |  | ||||||
|  | 		if s.Abort(c) { | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		var frm form.Selection | ||||||
|  |  | ||||||
|  | 		if err := c.BindJSON(&frm); err != nil { | ||||||
|  | 			AbortBadRequest(c) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		if len(frm.Labels) == 0 { | ||||||
|  | 			log.Error("no labels selected") | ||||||
|  | 			Abort(c, http.StatusBadRequest, i18n.ErrNoLabelsSelected) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		log.Infof("labels: deleting %s", clean.Log(frm.String())) | ||||||
|  |  | ||||||
|  | 		var labels entity.Labels | ||||||
|  |  | ||||||
|  | 		if err := entity.Db().Where("label_uid IN (?)", frm.Labels).Find(&labels).Error; err != nil { | ||||||
|  | 			Error(c, http.StatusInternalServerError, err, i18n.ErrDeleteFailed) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		for _, label := range labels { | ||||||
|  | 			logErr("labels", label.Delete()) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		UpdateClientConfig() | ||||||
|  |  | ||||||
|  | 		event.EntitiesDeleted("labels", frm.Labels) | ||||||
|  |  | ||||||
|  | 		c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgLabelsDeleted)) | ||||||
|  | 	}) | ||||||
|  | } | ||||||
| @@ -2,44 +2,45 @@ package api | |||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"net/http" | 	"net/http" | ||||||
|  | 	"path" | ||||||
|  | 	"time" | ||||||
|  |  | ||||||
|  | 	"github.com/dustin/go-humanize/english" | ||||||
|  |  | ||||||
| 	"github.com/gin-gonic/gin" | 	"github.com/gin-gonic/gin" | ||||||
|  | 	"github.com/jinzhu/gorm" | ||||||
|  |  | ||||||
| 	"github.com/photoprism/photoprism/internal/auth/acl" | 	"github.com/photoprism/photoprism/internal/auth/acl" | ||||||
|  | 	"github.com/photoprism/photoprism/internal/config" | ||||||
|  | 	"github.com/photoprism/photoprism/internal/entity" | ||||||
| 	"github.com/photoprism/photoprism/internal/entity/query" | 	"github.com/photoprism/photoprism/internal/entity/query" | ||||||
|  | 	"github.com/photoprism/photoprism/internal/event" | ||||||
| 	"github.com/photoprism/photoprism/internal/form" | 	"github.com/photoprism/photoprism/internal/form" | ||||||
| 	"github.com/photoprism/photoprism/internal/form/batch" | 	"github.com/photoprism/photoprism/internal/photoprism" | ||||||
| 	"github.com/photoprism/photoprism/internal/photoprism/get" | 	"github.com/photoprism/photoprism/internal/photoprism/get" | ||||||
| 	"github.com/photoprism/photoprism/pkg/clean" | 	"github.com/photoprism/photoprism/pkg/clean" | ||||||
| 	"github.com/photoprism/photoprism/pkg/i18n" | 	"github.com/photoprism/photoprism/pkg/i18n" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| // BatchPhotos returns the metadata of multiple pictures so that it can be edited. | // BatchPhotosArchive moves multiple pictures to the archive. | ||||||
| // | // | ||||||
| //	@Summary	returns the metadata of multiple pictures so that it can be edited | //	@Summary	moves multiple pictures to the archive | ||||||
| //	@Id			BatchPhotos | //	@Id			BatchPhotosArchive | ||||||
| //	@Tags		Photos | //	@Tags		Photos | ||||||
| //	@Accept		json | //	@Accept		json | ||||||
| //	@Produce	json | //	@Produce	json | ||||||
| //	@Success	200						{object}	batch.PhotoForm | //	@Success	200						{object}	i18n.Response | ||||||
| //	@Failure	400,401,403,404,429,500	{object}	i18n.Response | //	@Failure	400,401,403,404,429,500	{object}	i18n.Response | ||||||
| //	@Param		photos					body		form.Selection	true	"Photo Selection" | //	@Param		photos					body		form.Selection	true	"Photo Selection" | ||||||
| //	@Router		/api/v1/batch/photos [post] | //	@Router		/api/v1/batch/photos/archive [post] | ||||||
| func BatchPhotos(router *gin.RouterGroup) { | func BatchPhotosArchive(router *gin.RouterGroup) { | ||||||
| 	router.POST("/batch/photos", func(c *gin.Context) { | 	router.POST("/batch/photos/archive", func(c *gin.Context) { | ||||||
| 		s := Auth(c, acl.ResourcePhotos, acl.ActionUpdate) | 		s := Auth(c, acl.ResourcePhotos, acl.ActionDelete) | ||||||
|  |  | ||||||
| 		if s.Abort(c) { | 		if s.Abort(c) { | ||||||
| 			return | 			return | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		conf := get.Config() |  | ||||||
|  |  | ||||||
| 		if !conf.Develop() && !conf.Experimental() { |  | ||||||
| 			AbortNotImplemented(c) |  | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		var frm form.Selection | 		var frm form.Selection | ||||||
|  |  | ||||||
| 		// Assign and validate request form values. | 		// Assign and validate request form values. | ||||||
| @@ -53,28 +54,339 @@ func BatchPhotos(router *gin.RouterGroup) { | |||||||
| 			return | 			return | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		// Find selected photos. | 		log.Infof("photos: archiving %s", clean.Log(frm.String())) | ||||||
|  |  | ||||||
|  | 		if get.Config().SidecarYaml() { | ||||||
|  | 			// Fetch selection from index. | ||||||
| 			photos, err := query.SelectedPhotos(frm) | 			photos, err := query.SelectedPhotos(frm) | ||||||
|  |  | ||||||
| 			if err != nil { | 			if err != nil { | ||||||
| 			log.Errorf("batch: %s", clean.Error(err)) | 				AbortEntityNotFound(c) | ||||||
| 			AbortUnexpectedError(c) |  | ||||||
| 				return | 				return | ||||||
| 			} | 			} | ||||||
|  |  | ||||||
| 		// Load files and details. | 			for _, p := range photos { | ||||||
| 		for _, photo := range photos { | 				if archiveErr := p.Archive(); archiveErr != nil { | ||||||
| 			photo.PreloadFiles() | 					log.Errorf("archive: %s", archiveErr) | ||||||
| 			photo.GetDetails() | 				} 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{ | 		// Update album, subject, and label cover thumbs. | ||||||
| 			"photos": photos, | 		query.UpdateCoversAsync() | ||||||
| 			"values": batchFrm, |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		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 | package api | ||||||
|  |  | ||||||
| import ( | import ( | ||||||
|  | 	"encoding/json" | ||||||
|  | 	"fmt" | ||||||
| 	"net/http" | 	"net/http" | ||||||
| 	"strings" |  | ||||||
| 	"testing" | 	"testing" | ||||||
|  |  | ||||||
| 	"github.com/stretchr/testify/assert" | 	"github.com/stretchr/testify/assert" | ||||||
| @@ -11,42 +12,266 @@ import ( | |||||||
| 	"github.com/photoprism/photoprism/pkg/i18n" | 	"github.com/photoprism/photoprism/pkg/i18n" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| func TestBatchPhotos(t *testing.T) { | func TestBatchPhotosArchive(t *testing.T) { | ||||||
| 	t.Run("Success", func(t *testing.T) { | 	t.Run("Success", func(t *testing.T) { | ||||||
| 		app, router, _ := NewApiTest() | 		app, router, _ := NewApiTest() | ||||||
|  | 		GetPhoto(router) | ||||||
|  | 		r := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0yh7") | ||||||
|  | 		assert.Equal(t, http.StatusOK, r.Code) | ||||||
|  | 		val := gjson.Get(r.Body.String(), "DeletedAt") | ||||||
|  | 		assert.Empty(t, val.String()) | ||||||
|  |  | ||||||
| 		BatchPhotos(router) | 		BatchPhotosArchive(router) | ||||||
|  | 		r2 := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/archive", `{"photos": ["ps6sg6be2lvl0yh7", "ps6sg6be2lvl0ycc"]}`) | ||||||
|  | 		val2 := gjson.Get(r2.Body.String(), "message") | ||||||
|  | 		assert.Contains(t, val2.String(), "Selection archived") | ||||||
|  | 		assert.Equal(t, http.StatusOK, r2.Code) | ||||||
|  |  | ||||||
| 		response := PerformRequestWithBody(app, | 		r3 := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0yh7") | ||||||
| 			"POST", "/api/v1/batch/photos", | 		assert.Equal(t, http.StatusOK, r3.Code) | ||||||
| 			`{"photos": ["ps6sg6be2lvl0yh7", "ps6sg6be2lvl0yh8", "ps6sg6be2lvl0ycc"]}`, | 		val3 := gjson.Get(r3.Body.String(), "DeletedAt") | ||||||
| 		) | 		assert.NotEmpty(t, val3.String()) | ||||||
|  |  | ||||||
| 		body := response.Body.String() |  | ||||||
|  |  | ||||||
| 		assert.NotEmpty(t, body) |  | ||||||
| 		assert.True(t, strings.HasPrefix(body, `{"photos":[{"ID"`), "unexpected response") |  | ||||||
|  |  | ||||||
| 		// fmt.Println(body) |  | ||||||
| 		/* photos := gjson.Get(body, "photos") |  | ||||||
| 		values := gjson.Get(body, "values") |  | ||||||
| 		t.Logf("photos: %#v", photos) |  | ||||||
| 		t.Logf("values: %#v", values) */ |  | ||||||
|  |  | ||||||
| 		assert.Equal(t, http.StatusOK, response.Code) |  | ||||||
| 	}) | 	}) | ||||||
| 	t.Run("MissingSelection", func(t *testing.T) { | 	t.Run("MissingSelection", func(t *testing.T) { | ||||||
| 		app, router, _ := NewApiTest() | 		app, router, _ := NewApiTest() | ||||||
| 		BatchPhotos(router) | 		BatchPhotosArchive(router) | ||||||
| 		r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos", `{"photos": []}`) | 		r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/archive", `{"photos": []}`) | ||||||
| 		val := gjson.Get(r.Body.String(), "error") | 		val := gjson.Get(r.Body.String(), "error") | ||||||
| 		assert.Equal(t, i18n.Msg(i18n.ErrNoItemsSelected), val.String()) | 		assert.Equal(t, i18n.Msg(i18n.ErrNoItemsSelected), val.String()) | ||||||
| 		assert.Equal(t, http.StatusBadRequest, r.Code) | 		assert.Equal(t, http.StatusBadRequest, r.Code) | ||||||
| 	}) | 	}) | ||||||
| 	t.Run("InvalidRequest", func(t *testing.T) { | 	t.Run("InvalidRequest", func(t *testing.T) { | ||||||
| 		app, router, _ := NewApiTest() | 		app, router, _ := NewApiTest() | ||||||
| 		BatchPhotos(router) | 		BatchPhotosArchive(router) | ||||||
| 		r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos", `{"photos": 123}`) | 		r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/archive", `{"photos": 123}`) | ||||||
|  | 		assert.Equal(t, http.StatusBadRequest, r.Code) | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func TestBatchPhotosRestore(t *testing.T) { | ||||||
|  | 	t.Run("Success", func(t *testing.T) { | ||||||
|  | 		app, router, _ := NewApiTest() | ||||||
|  |  | ||||||
|  | 		// Register routes. | ||||||
|  | 		BatchPhotosArchive(router) | ||||||
|  | 		GetPhoto(router) | ||||||
|  | 		BatchPhotosRestore(router) | ||||||
|  |  | ||||||
|  | 		r2 := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/archive", `{"photos": ["ps6sg6be2lvl0yh8", "ps6sg6be2lvl0ycc"]}`) | ||||||
|  | 		val2 := gjson.Get(r2.Body.String(), "message") | ||||||
|  | 		assert.Contains(t, val2.String(), "Selection archived") | ||||||
|  | 		assert.Equal(t, http.StatusOK, r2.Code) | ||||||
|  |  | ||||||
|  | 		r3 := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0yh8") | ||||||
|  | 		assert.Equal(t, http.StatusOK, r3.Code) | ||||||
|  | 		val3 := gjson.Get(r3.Body.String(), "DeletedAt") | ||||||
|  | 		assert.NotEmpty(t, val3.String()) | ||||||
|  |  | ||||||
|  | 		r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/restore", `{"photos": ["ps6sg6be2lvl0yh8", "ps6sg6be2lvl0ycc"]}`) | ||||||
|  | 		val := gjson.Get(r.Body.String(), "message") | ||||||
|  | 		assert.Contains(t, val.String(), "Selection restored") | ||||||
|  | 		assert.Equal(t, http.StatusOK, r.Code) | ||||||
|  |  | ||||||
|  | 		r4 := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0yh8") | ||||||
|  | 		assert.Equal(t, http.StatusOK, r4.Code) | ||||||
|  | 		val4 := gjson.Get(r4.Body.String(), "DeletedAt") | ||||||
|  | 		assert.Empty(t, val4.String()) | ||||||
|  | 	}) | ||||||
|  | 	t.Run("MissingSelection", func(t *testing.T) { | ||||||
|  | 		app, router, _ := NewApiTest() | ||||||
|  | 		BatchPhotosRestore(router) | ||||||
|  | 		r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/restore", `{"photos": []}`) | ||||||
|  | 		val := gjson.Get(r.Body.String(), "error") | ||||||
|  | 		assert.Equal(t, i18n.Msg(i18n.ErrNoItemsSelected), val.String()) | ||||||
|  | 		assert.Equal(t, http.StatusBadRequest, r.Code) | ||||||
|  | 	}) | ||||||
|  | 	t.Run("InvalidRequest", func(t *testing.T) { | ||||||
|  | 		app, router, _ := NewApiTest() | ||||||
|  | 		BatchPhotosRestore(router) | ||||||
|  | 		r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/restore", `{"photos": 123}`) | ||||||
|  | 		assert.Equal(t, http.StatusBadRequest, r.Code) | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func TestBatchAlbumsDelete(t *testing.T) { | ||||||
|  | 	app, router, _ := NewApiTest() | ||||||
|  | 	CreateAlbum(router) | ||||||
|  | 	r := PerformRequestWithBody(app, "POST", "/api/v1/albums", `{"Title": "BatchDelete", "Description": "To be deleted", "Notes": "", "Favorite": true}`) | ||||||
|  | 	assert.Equal(t, http.StatusOK, r.Code) | ||||||
|  | 	uid := gjson.Get(r.Body.String(), "UID").String() | ||||||
|  |  | ||||||
|  | 	t.Run("Success", func(t *testing.T) { | ||||||
|  | 		app, router, _ := NewApiTest() | ||||||
|  |  | ||||||
|  | 		// Register routes. | ||||||
|  | 		GetAlbum(router) | ||||||
|  | 		BatchAlbumsDelete(router) | ||||||
|  |  | ||||||
|  | 		r := PerformRequest(app, "GET", "/api/v1/albums/"+uid) | ||||||
|  | 		val := gjson.Get(r.Body.String(), "Slug") | ||||||
|  | 		assert.Equal(t, "batchdelete", val.String()) | ||||||
|  |  | ||||||
|  | 		r2 := PerformRequestWithBody(app, "POST", "/api/v1/batch/albums/delete", fmt.Sprintf(`{"albums": ["%s", "ps6sg6be2lvl0ycc"]}`, uid)) | ||||||
|  | 		val2 := gjson.Get(r2.Body.String(), "message") | ||||||
|  | 		assert.Contains(t, val2.String(), i18n.Msg(i18n.MsgAlbumsDeleted)) | ||||||
|  | 		assert.Equal(t, http.StatusOK, r2.Code) | ||||||
|  |  | ||||||
|  | 		r3 := PerformRequest(app, "GET", "/api/v1/albums/"+uid) | ||||||
|  | 		val3 := gjson.Get(r3.Body.String(), "error") | ||||||
|  | 		assert.Equal(t, i18n.Msg(i18n.ErrAlbumNotFound), val3.String()) | ||||||
|  | 		assert.Equal(t, http.StatusNotFound, r3.Code) | ||||||
|  | 	}) | ||||||
|  | 	t.Run("no albums selected", func(t *testing.T) { | ||||||
|  | 		app, router, _ := NewApiTest() | ||||||
|  | 		BatchAlbumsDelete(router) | ||||||
|  | 		r := PerformRequestWithBody(app, "POST", "/api/v1/batch/albums/delete", `{"albums": []}`) | ||||||
|  | 		val := gjson.Get(r.Body.String(), "error") | ||||||
|  | 		assert.Equal(t, i18n.Msg(i18n.ErrNoAlbumsSelected), val.String()) | ||||||
|  | 		assert.Equal(t, http.StatusBadRequest, r.Code) | ||||||
|  | 	}) | ||||||
|  | 	t.Run("InvalidRequest", func(t *testing.T) { | ||||||
|  | 		app, router, _ := NewApiTest() | ||||||
|  | 		BatchAlbumsDelete(router) | ||||||
|  | 		r := PerformRequestWithBody(app, "POST", "/api/v1/batch/albums/delete", `{"albums": 123}`) | ||||||
|  | 		assert.Equal(t, http.StatusBadRequest, r.Code) | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func TestBatchPhotosPrivate(t *testing.T) { | ||||||
|  | 	t.Run("Success", func(t *testing.T) { | ||||||
|  | 		app, router, _ := NewApiTest() | ||||||
|  |  | ||||||
|  | 		// Register routes. | ||||||
|  | 		GetPhoto(router) | ||||||
|  | 		BatchPhotosPrivate(router) | ||||||
|  |  | ||||||
|  | 		r := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0yh8") | ||||||
|  | 		assert.Equal(t, http.StatusOK, r.Code) | ||||||
|  | 		val := gjson.Get(r.Body.String(), "Private") | ||||||
|  | 		assert.Equal(t, "false", val.String()) | ||||||
|  |  | ||||||
|  | 		r2 := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/private", `{"photos": ["ps6sg6be2lvl0yh8", "ps6sg6be2lvl0ycc"]}`) | ||||||
|  | 		val2 := gjson.Get(r2.Body.String(), "message") | ||||||
|  | 		assert.Contains(t, val2.String(), "Selection marked as private") | ||||||
|  | 		assert.Equal(t, http.StatusOK, r2.Code) | ||||||
|  |  | ||||||
|  | 		r3 := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0yh8") | ||||||
|  | 		assert.Equal(t, http.StatusOK, r3.Code) | ||||||
|  | 		val3 := gjson.Get(r3.Body.String(), "Private") | ||||||
|  | 		assert.Equal(t, "true", val3.String()) | ||||||
|  | 	}) | ||||||
|  | 	t.Run("MissingSelection", func(t *testing.T) { | ||||||
|  | 		app, router, _ := NewApiTest() | ||||||
|  | 		BatchPhotosPrivate(router) | ||||||
|  | 		r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/private", `{"photos": []}`) | ||||||
|  | 		val := gjson.Get(r.Body.String(), "error") | ||||||
|  | 		assert.Equal(t, i18n.Msg(i18n.ErrNoItemsSelected), val.String()) | ||||||
|  | 		assert.Equal(t, http.StatusBadRequest, r.Code) | ||||||
|  | 	}) | ||||||
|  | 	t.Run("InvalidRequest", func(t *testing.T) { | ||||||
|  | 		app, router, _ := NewApiTest() | ||||||
|  | 		BatchPhotosPrivate(router) | ||||||
|  | 		r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/private", `{"photos": 123}`) | ||||||
|  | 		assert.Equal(t, http.StatusBadRequest, r.Code) | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func TestBatchLabelsDelete(t *testing.T) { | ||||||
|  | 	t.Run("Success", func(t *testing.T) { | ||||||
|  | 		app, router, _ := NewApiTest() | ||||||
|  |  | ||||||
|  | 		// Register routes. | ||||||
|  | 		SearchLabels(router) | ||||||
|  | 		BatchLabelsDelete(router) | ||||||
|  |  | ||||||
|  | 		r := PerformRequest(app, "GET", "/api/v1/labels?count=15") | ||||||
|  | 		val := gjson.Get(r.Body.String(), `#(Name=="Batch Delete").Slug`) | ||||||
|  | 		assert.Equal(t, val.String(), "batch-delete") | ||||||
|  |  | ||||||
|  | 		r2 := PerformRequestWithBody(app, "POST", "/api/v1/batch/labels/delete", `{"labels": ["ls6sg6b1wowuy3c6", "ps6sg6be2lvl0ycc"]}`) | ||||||
|  |  | ||||||
|  | 		var resp i18n.Response | ||||||
|  |  | ||||||
|  | 		if err := json.Unmarshal(r2.Body.Bytes(), &resp); err != nil { | ||||||
|  | 			t.Fatal(err) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		assert.True(t, resp.Success()) | ||||||
|  | 		assert.Equal(t, i18n.Msg(i18n.MsgLabelsDeleted), resp.Msg) | ||||||
|  | 		assert.Equal(t, i18n.Msg(i18n.MsgLabelsDeleted), resp.String()) | ||||||
|  | 		assert.Equal(t, http.StatusOK, r2.Code) | ||||||
|  | 		assert.Equal(t, http.StatusOK, resp.Code) | ||||||
|  |  | ||||||
|  | 		r3 := PerformRequest(app, "GET", "/api/v1/labels?count=15") | ||||||
|  | 		val3 := gjson.Get(r3.Body.String(), `#(Name=="BatchDelete").Slug`) | ||||||
|  | 		assert.Equal(t, val3.String(), "") | ||||||
|  | 	}) | ||||||
|  | 	t.Run("no labels selected", func(t *testing.T) { | ||||||
|  | 		app, router, _ := NewApiTest() | ||||||
|  | 		BatchLabelsDelete(router) | ||||||
|  | 		r := PerformRequestWithBody(app, "POST", "/api/v1/batch/labels/delete", `{"labels": []}`) | ||||||
|  | 		val := gjson.Get(r.Body.String(), "error") | ||||||
|  | 		assert.Equal(t, i18n.Msg(i18n.ErrNoLabelsSelected), val.String()) | ||||||
|  | 		assert.Equal(t, http.StatusBadRequest, r.Code) | ||||||
|  | 	}) | ||||||
|  | 	t.Run("InvalidRequest", func(t *testing.T) { | ||||||
|  | 		app, router, _ := NewApiTest() | ||||||
|  | 		BatchLabelsDelete(router) | ||||||
|  | 		r := PerformRequestWithBody(app, "POST", "/api/v1/batch/labels/delete", `{"labels": 123}`) | ||||||
|  | 		assert.Equal(t, http.StatusBadRequest, r.Code) | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func TestBatchPhotosApprove(t *testing.T) { | ||||||
|  | 	t.Run("Success", func(t *testing.T) { | ||||||
|  | 		app, router, _ := NewApiTest() | ||||||
|  |  | ||||||
|  | 		// Register routes. | ||||||
|  | 		GetPhoto(router) | ||||||
|  | 		BatchPhotosApprove(router) | ||||||
|  |  | ||||||
|  | 		r := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0y50") | ||||||
|  | 		assert.Equal(t, http.StatusOK, r.Code) | ||||||
|  | 		val := gjson.Get(r.Body.String(), "Quality") | ||||||
|  | 		assert.Equal(t, "1", val.String()) | ||||||
|  | 		val4 := gjson.Get(r.Body.String(), "EditedAt") | ||||||
|  | 		assert.Empty(t, val4.String()) | ||||||
|  |  | ||||||
|  | 		r2 := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/approve", `{"photos": ["ps6sg6be2lvl0y50", "ps6sg6be2lvl0y90"]}`) | ||||||
|  | 		val2 := gjson.Get(r2.Body.String(), "message") | ||||||
|  | 		assert.Contains(t, val2.String(), "Selection approved") | ||||||
|  | 		assert.Equal(t, http.StatusOK, r2.Code) | ||||||
|  |  | ||||||
|  | 		r3 := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0y50") | ||||||
|  | 		assert.Equal(t, http.StatusOK, r3.Code) | ||||||
|  | 		val5 := gjson.Get(r3.Body.String(), "Quality") | ||||||
|  | 		assert.Equal(t, "7", val5.String()) | ||||||
|  | 		val6 := gjson.Get(r3.Body.String(), "EditedAt") | ||||||
|  | 		assert.NotEmpty(t, val6.String()) | ||||||
|  | 	}) | ||||||
|  | 	t.Run("MissingSelection", func(t *testing.T) { | ||||||
|  | 		app, router, _ := NewApiTest() | ||||||
|  | 		BatchPhotosApprove(router) | ||||||
|  | 		r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/approve", `{"photos": []}`) | ||||||
|  | 		val := gjson.Get(r.Body.String(), "error") | ||||||
|  | 		assert.Equal(t, i18n.Msg(i18n.ErrNoItemsSelected), val.String()) | ||||||
|  | 		assert.Equal(t, http.StatusBadRequest, r.Code) | ||||||
|  | 	}) | ||||||
|  | 	t.Run("InvalidRequest", func(t *testing.T) { | ||||||
|  | 		app, router, _ := NewApiTest() | ||||||
|  | 		BatchPhotosApprove(router) | ||||||
|  | 		r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/approve", `{"photos": 123}`) | ||||||
|  | 		assert.Equal(t, http.StatusBadRequest, r.Code) | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func TestBatchPhotosDelete(t *testing.T) { | ||||||
|  | 	t.Run("ErrNoItemsSelected", func(t *testing.T) { | ||||||
|  | 		app, router, _ := NewApiTest() | ||||||
|  | 		BatchPhotosDelete(router) | ||||||
|  | 		r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/delete", `{"photos": []}`) | ||||||
|  | 		val := gjson.Get(r.Body.String(), "error") | ||||||
|  | 		assert.Equal(t, i18n.Msg(i18n.ErrNoItemsSelected), val.String()) | ||||||
| 		assert.Equal(t, http.StatusBadRequest, r.Code) | 		assert.Equal(t, http.StatusBadRequest, r.Code) | ||||||
| 	}) | 	}) | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,277 +0,0 @@ | |||||||
| package api |  | ||||||
|  |  | ||||||
| import ( |  | ||||||
| 	"encoding/json" |  | ||||||
| 	"fmt" |  | ||||||
| 	"net/http" |  | ||||||
| 	"testing" |  | ||||||
|  |  | ||||||
| 	"github.com/stretchr/testify/assert" |  | ||||||
| 	"github.com/tidwall/gjson" |  | ||||||
|  |  | ||||||
| 	"github.com/photoprism/photoprism/pkg/i18n" |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| func TestBatchPhotosArchive(t *testing.T) { |  | ||||||
| 	t.Run("Success", func(t *testing.T) { |  | ||||||
| 		app, router, _ := NewApiTest() |  | ||||||
| 		GetPhoto(router) |  | ||||||
| 		r := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0yh7") |  | ||||||
| 		assert.Equal(t, http.StatusOK, r.Code) |  | ||||||
| 		val := gjson.Get(r.Body.String(), "DeletedAt") |  | ||||||
| 		assert.Empty(t, val.String()) |  | ||||||
|  |  | ||||||
| 		BatchPhotosArchive(router) |  | ||||||
| 		r2 := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/archive", `{"photos": ["ps6sg6be2lvl0yh7", "ps6sg6be2lvl0ycc"]}`) |  | ||||||
| 		val2 := gjson.Get(r2.Body.String(), "message") |  | ||||||
| 		assert.Contains(t, val2.String(), "Selection archived") |  | ||||||
| 		assert.Equal(t, http.StatusOK, r2.Code) |  | ||||||
|  |  | ||||||
| 		r3 := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0yh7") |  | ||||||
| 		assert.Equal(t, http.StatusOK, r3.Code) |  | ||||||
| 		val3 := gjson.Get(r3.Body.String(), "DeletedAt") |  | ||||||
| 		assert.NotEmpty(t, val3.String()) |  | ||||||
| 	}) |  | ||||||
| 	t.Run("MissingSelection", func(t *testing.T) { |  | ||||||
| 		app, router, _ := NewApiTest() |  | ||||||
| 		BatchPhotosArchive(router) |  | ||||||
| 		r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/archive", `{"photos": []}`) |  | ||||||
| 		val := gjson.Get(r.Body.String(), "error") |  | ||||||
| 		assert.Equal(t, i18n.Msg(i18n.ErrNoItemsSelected), val.String()) |  | ||||||
| 		assert.Equal(t, http.StatusBadRequest, r.Code) |  | ||||||
| 	}) |  | ||||||
| 	t.Run("InvalidRequest", func(t *testing.T) { |  | ||||||
| 		app, router, _ := NewApiTest() |  | ||||||
| 		BatchPhotosArchive(router) |  | ||||||
| 		r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/archive", `{"photos": 123}`) |  | ||||||
| 		assert.Equal(t, http.StatusBadRequest, r.Code) |  | ||||||
| 	}) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func TestBatchPhotosRestore(t *testing.T) { |  | ||||||
| 	t.Run("Success", func(t *testing.T) { |  | ||||||
| 		app, router, _ := NewApiTest() |  | ||||||
|  |  | ||||||
| 		// Register routes. |  | ||||||
| 		BatchPhotosArchive(router) |  | ||||||
| 		GetPhoto(router) |  | ||||||
| 		BatchPhotosRestore(router) |  | ||||||
|  |  | ||||||
| 		r2 := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/archive", `{"photos": ["ps6sg6be2lvl0yh8", "ps6sg6be2lvl0ycc"]}`) |  | ||||||
| 		val2 := gjson.Get(r2.Body.String(), "message") |  | ||||||
| 		assert.Contains(t, val2.String(), "Selection archived") |  | ||||||
| 		assert.Equal(t, http.StatusOK, r2.Code) |  | ||||||
|  |  | ||||||
| 		r3 := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0yh8") |  | ||||||
| 		assert.Equal(t, http.StatusOK, r3.Code) |  | ||||||
| 		val3 := gjson.Get(r3.Body.String(), "DeletedAt") |  | ||||||
| 		assert.NotEmpty(t, val3.String()) |  | ||||||
|  |  | ||||||
| 		r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/restore", `{"photos": ["ps6sg6be2lvl0yh8", "ps6sg6be2lvl0ycc"]}`) |  | ||||||
| 		val := gjson.Get(r.Body.String(), "message") |  | ||||||
| 		assert.Contains(t, val.String(), "Selection restored") |  | ||||||
| 		assert.Equal(t, http.StatusOK, r.Code) |  | ||||||
|  |  | ||||||
| 		r4 := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0yh8") |  | ||||||
| 		assert.Equal(t, http.StatusOK, r4.Code) |  | ||||||
| 		val4 := gjson.Get(r4.Body.String(), "DeletedAt") |  | ||||||
| 		assert.Empty(t, val4.String()) |  | ||||||
| 	}) |  | ||||||
| 	t.Run("MissingSelection", func(t *testing.T) { |  | ||||||
| 		app, router, _ := NewApiTest() |  | ||||||
| 		BatchPhotosRestore(router) |  | ||||||
| 		r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/restore", `{"photos": []}`) |  | ||||||
| 		val := gjson.Get(r.Body.String(), "error") |  | ||||||
| 		assert.Equal(t, i18n.Msg(i18n.ErrNoItemsSelected), val.String()) |  | ||||||
| 		assert.Equal(t, http.StatusBadRequest, r.Code) |  | ||||||
| 	}) |  | ||||||
| 	t.Run("InvalidRequest", func(t *testing.T) { |  | ||||||
| 		app, router, _ := NewApiTest() |  | ||||||
| 		BatchPhotosRestore(router) |  | ||||||
| 		r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/restore", `{"photos": 123}`) |  | ||||||
| 		assert.Equal(t, http.StatusBadRequest, r.Code) |  | ||||||
| 	}) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func TestBatchAlbumsDelete(t *testing.T) { |  | ||||||
| 	app, router, _ := NewApiTest() |  | ||||||
| 	CreateAlbum(router) |  | ||||||
| 	r := PerformRequestWithBody(app, "POST", "/api/v1/albums", `{"Title": "BatchDelete", "Description": "To be deleted", "Notes": "", "Favorite": true}`) |  | ||||||
| 	assert.Equal(t, http.StatusOK, r.Code) |  | ||||||
| 	uid := gjson.Get(r.Body.String(), "UID").String() |  | ||||||
|  |  | ||||||
| 	t.Run("Success", func(t *testing.T) { |  | ||||||
| 		app, router, _ := NewApiTest() |  | ||||||
|  |  | ||||||
| 		// Register routes. |  | ||||||
| 		GetAlbum(router) |  | ||||||
| 		BatchAlbumsDelete(router) |  | ||||||
|  |  | ||||||
| 		r := PerformRequest(app, "GET", "/api/v1/albums/"+uid) |  | ||||||
| 		val := gjson.Get(r.Body.String(), "Slug") |  | ||||||
| 		assert.Equal(t, "batchdelete", val.String()) |  | ||||||
|  |  | ||||||
| 		r2 := PerformRequestWithBody(app, "POST", "/api/v1/batch/albums/delete", fmt.Sprintf(`{"albums": ["%s", "ps6sg6be2lvl0ycc"]}`, uid)) |  | ||||||
| 		val2 := gjson.Get(r2.Body.String(), "message") |  | ||||||
| 		assert.Contains(t, val2.String(), i18n.Msg(i18n.MsgAlbumsDeleted)) |  | ||||||
| 		assert.Equal(t, http.StatusOK, r2.Code) |  | ||||||
|  |  | ||||||
| 		r3 := PerformRequest(app, "GET", "/api/v1/albums/"+uid) |  | ||||||
| 		val3 := gjson.Get(r3.Body.String(), "error") |  | ||||||
| 		assert.Equal(t, i18n.Msg(i18n.ErrAlbumNotFound), val3.String()) |  | ||||||
| 		assert.Equal(t, http.StatusNotFound, r3.Code) |  | ||||||
| 	}) |  | ||||||
| 	t.Run("no albums selected", func(t *testing.T) { |  | ||||||
| 		app, router, _ := NewApiTest() |  | ||||||
| 		BatchAlbumsDelete(router) |  | ||||||
| 		r := PerformRequestWithBody(app, "POST", "/api/v1/batch/albums/delete", `{"albums": []}`) |  | ||||||
| 		val := gjson.Get(r.Body.String(), "error") |  | ||||||
| 		assert.Equal(t, i18n.Msg(i18n.ErrNoAlbumsSelected), val.String()) |  | ||||||
| 		assert.Equal(t, http.StatusBadRequest, r.Code) |  | ||||||
| 	}) |  | ||||||
| 	t.Run("InvalidRequest", func(t *testing.T) { |  | ||||||
| 		app, router, _ := NewApiTest() |  | ||||||
| 		BatchAlbumsDelete(router) |  | ||||||
| 		r := PerformRequestWithBody(app, "POST", "/api/v1/batch/albums/delete", `{"albums": 123}`) |  | ||||||
| 		assert.Equal(t, http.StatusBadRequest, r.Code) |  | ||||||
| 	}) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func TestBatchPhotosPrivate(t *testing.T) { |  | ||||||
| 	t.Run("Success", func(t *testing.T) { |  | ||||||
| 		app, router, _ := NewApiTest() |  | ||||||
|  |  | ||||||
| 		// Register routes. |  | ||||||
| 		GetPhoto(router) |  | ||||||
| 		BatchPhotosPrivate(router) |  | ||||||
|  |  | ||||||
| 		r := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0yh8") |  | ||||||
| 		assert.Equal(t, http.StatusOK, r.Code) |  | ||||||
| 		val := gjson.Get(r.Body.String(), "Private") |  | ||||||
| 		assert.Equal(t, "false", val.String()) |  | ||||||
|  |  | ||||||
| 		r2 := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/private", `{"photos": ["ps6sg6be2lvl0yh8", "ps6sg6be2lvl0ycc"]}`) |  | ||||||
| 		val2 := gjson.Get(r2.Body.String(), "message") |  | ||||||
| 		assert.Contains(t, val2.String(), "Selection marked as private") |  | ||||||
| 		assert.Equal(t, http.StatusOK, r2.Code) |  | ||||||
|  |  | ||||||
| 		r3 := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0yh8") |  | ||||||
| 		assert.Equal(t, http.StatusOK, r3.Code) |  | ||||||
| 		val3 := gjson.Get(r3.Body.String(), "Private") |  | ||||||
| 		assert.Equal(t, "true", val3.String()) |  | ||||||
| 	}) |  | ||||||
| 	t.Run("MissingSelection", func(t *testing.T) { |  | ||||||
| 		app, router, _ := NewApiTest() |  | ||||||
| 		BatchPhotosPrivate(router) |  | ||||||
| 		r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/private", `{"photos": []}`) |  | ||||||
| 		val := gjson.Get(r.Body.String(), "error") |  | ||||||
| 		assert.Equal(t, i18n.Msg(i18n.ErrNoItemsSelected), val.String()) |  | ||||||
| 		assert.Equal(t, http.StatusBadRequest, r.Code) |  | ||||||
| 	}) |  | ||||||
| 	t.Run("InvalidRequest", func(t *testing.T) { |  | ||||||
| 		app, router, _ := NewApiTest() |  | ||||||
| 		BatchPhotosPrivate(router) |  | ||||||
| 		r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/private", `{"photos": 123}`) |  | ||||||
| 		assert.Equal(t, http.StatusBadRequest, r.Code) |  | ||||||
| 	}) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func TestBatchLabelsDelete(t *testing.T) { |  | ||||||
| 	t.Run("Success", func(t *testing.T) { |  | ||||||
| 		app, router, _ := NewApiTest() |  | ||||||
|  |  | ||||||
| 		// Register routes. |  | ||||||
| 		SearchLabels(router) |  | ||||||
| 		BatchLabelsDelete(router) |  | ||||||
|  |  | ||||||
| 		r := PerformRequest(app, "GET", "/api/v1/labels?count=15") |  | ||||||
| 		val := gjson.Get(r.Body.String(), `#(Name=="Batch Delete").Slug`) |  | ||||||
| 		assert.Equal(t, val.String(), "batch-delete") |  | ||||||
|  |  | ||||||
| 		r2 := PerformRequestWithBody(app, "POST", "/api/v1/batch/labels/delete", `{"labels": ["ls6sg6b1wowuy3c6", "ps6sg6be2lvl0ycc"]}`) |  | ||||||
|  |  | ||||||
| 		var resp i18n.Response |  | ||||||
|  |  | ||||||
| 		if err := json.Unmarshal(r2.Body.Bytes(), &resp); err != nil { |  | ||||||
| 			t.Fatal(err) |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		assert.True(t, resp.Success()) |  | ||||||
| 		assert.Equal(t, i18n.Msg(i18n.MsgLabelsDeleted), resp.Msg) |  | ||||||
| 		assert.Equal(t, i18n.Msg(i18n.MsgLabelsDeleted), resp.String()) |  | ||||||
| 		assert.Equal(t, http.StatusOK, r2.Code) |  | ||||||
| 		assert.Equal(t, http.StatusOK, resp.Code) |  | ||||||
|  |  | ||||||
| 		r3 := PerformRequest(app, "GET", "/api/v1/labels?count=15") |  | ||||||
| 		val3 := gjson.Get(r3.Body.String(), `#(Name=="BatchDelete").Slug`) |  | ||||||
| 		assert.Equal(t, val3.String(), "") |  | ||||||
| 	}) |  | ||||||
| 	t.Run("no labels selected", func(t *testing.T) { |  | ||||||
| 		app, router, _ := NewApiTest() |  | ||||||
| 		BatchLabelsDelete(router) |  | ||||||
| 		r := PerformRequestWithBody(app, "POST", "/api/v1/batch/labels/delete", `{"labels": []}`) |  | ||||||
| 		val := gjson.Get(r.Body.String(), "error") |  | ||||||
| 		assert.Equal(t, i18n.Msg(i18n.ErrNoLabelsSelected), val.String()) |  | ||||||
| 		assert.Equal(t, http.StatusBadRequest, r.Code) |  | ||||||
| 	}) |  | ||||||
| 	t.Run("InvalidRequest", func(t *testing.T) { |  | ||||||
| 		app, router, _ := NewApiTest() |  | ||||||
| 		BatchLabelsDelete(router) |  | ||||||
| 		r := PerformRequestWithBody(app, "POST", "/api/v1/batch/labels/delete", `{"labels": 123}`) |  | ||||||
| 		assert.Equal(t, http.StatusBadRequest, r.Code) |  | ||||||
| 	}) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func TestBatchPhotosApprove(t *testing.T) { |  | ||||||
| 	t.Run("Success", func(t *testing.T) { |  | ||||||
| 		app, router, _ := NewApiTest() |  | ||||||
|  |  | ||||||
| 		// Register routes. |  | ||||||
| 		GetPhoto(router) |  | ||||||
| 		BatchPhotosApprove(router) |  | ||||||
|  |  | ||||||
| 		r := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0y50") |  | ||||||
| 		assert.Equal(t, http.StatusOK, r.Code) |  | ||||||
| 		val := gjson.Get(r.Body.String(), "Quality") |  | ||||||
| 		assert.Equal(t, "1", val.String()) |  | ||||||
| 		val4 := gjson.Get(r.Body.String(), "EditedAt") |  | ||||||
| 		assert.Empty(t, val4.String()) |  | ||||||
|  |  | ||||||
| 		r2 := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/approve", `{"photos": ["ps6sg6be2lvl0y50", "ps6sg6be2lvl0y90"]}`) |  | ||||||
| 		val2 := gjson.Get(r2.Body.String(), "message") |  | ||||||
| 		assert.Contains(t, val2.String(), "Selection approved") |  | ||||||
| 		assert.Equal(t, http.StatusOK, r2.Code) |  | ||||||
|  |  | ||||||
| 		r3 := PerformRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0y50") |  | ||||||
| 		assert.Equal(t, http.StatusOK, r3.Code) |  | ||||||
| 		val5 := gjson.Get(r3.Body.String(), "Quality") |  | ||||||
| 		assert.Equal(t, "7", val5.String()) |  | ||||||
| 		val6 := gjson.Get(r3.Body.String(), "EditedAt") |  | ||||||
| 		assert.NotEmpty(t, val6.String()) |  | ||||||
| 	}) |  | ||||||
| 	t.Run("MissingSelection", func(t *testing.T) { |  | ||||||
| 		app, router, _ := NewApiTest() |  | ||||||
| 		BatchPhotosApprove(router) |  | ||||||
| 		r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/approve", `{"photos": []}`) |  | ||||||
| 		val := gjson.Get(r.Body.String(), "error") |  | ||||||
| 		assert.Equal(t, i18n.Msg(i18n.ErrNoItemsSelected), val.String()) |  | ||||||
| 		assert.Equal(t, http.StatusBadRequest, r.Code) |  | ||||||
| 	}) |  | ||||||
| 	t.Run("InvalidRequest", func(t *testing.T) { |  | ||||||
| 		app, router, _ := NewApiTest() |  | ||||||
| 		BatchPhotosApprove(router) |  | ||||||
| 		r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/approve", `{"photos": 123}`) |  | ||||||
| 		assert.Equal(t, http.StatusBadRequest, r.Code) |  | ||||||
| 	}) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func TestBatchPhotosDelete(t *testing.T) { |  | ||||||
| 	t.Run("ErrNoItemsSelected", func(t *testing.T) { |  | ||||||
| 		app, router, _ := NewApiTest() |  | ||||||
| 		BatchPhotosDelete(router) |  | ||||||
| 		r := PerformRequestWithBody(app, "POST", "/api/v1/batch/photos/delete", `{"photos": []}`) |  | ||||||
| 		val := gjson.Get(r.Body.String(), "error") |  | ||||||
| 		assert.Equal(t, i18n.Msg(i18n.ErrNoItemsSelected), val.String()) |  | ||||||
| 		assert.Equal(t, http.StatusBadRequest, r.Code) |  | ||||||
| 	}) |  | ||||||
| } |  | ||||||
| @@ -1168,76 +1168,6 @@ | |||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|         }, |         }, | ||||||
|         "/api/v1/batch/photos": { |  | ||||||
|             "post": { |  | ||||||
|                 "consumes": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "produces": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "tags": [ |  | ||||||
|                     "Photos" |  | ||||||
|                 ], |  | ||||||
|                 "summary": "returns the metadata of multiple pictures so that it can be edited", |  | ||||||
|                 "operationId": "BatchPhotos", |  | ||||||
|                 "parameters": [ |  | ||||||
|                     { |  | ||||||
|                         "description": "Photo Selection", |  | ||||||
|                         "name": "photos", |  | ||||||
|                         "in": "body", |  | ||||||
|                         "required": true, |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/form.Selection" |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 ], |  | ||||||
|                 "responses": { |  | ||||||
|                     "200": { |  | ||||||
|                         "description": "OK", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/batch.PhotoForm" |  | ||||||
|                         } |  | ||||||
|                     }, |  | ||||||
|                     "400": { |  | ||||||
|                         "description": "Bad Request", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/i18n.Response" |  | ||||||
|                         } |  | ||||||
|                     }, |  | ||||||
|                     "401": { |  | ||||||
|                         "description": "Unauthorized", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/i18n.Response" |  | ||||||
|                         } |  | ||||||
|                     }, |  | ||||||
|                     "403": { |  | ||||||
|                         "description": "Forbidden", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/i18n.Response" |  | ||||||
|                         } |  | ||||||
|                     }, |  | ||||||
|                     "404": { |  | ||||||
|                         "description": "Not Found", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/i18n.Response" |  | ||||||
|                         } |  | ||||||
|                     }, |  | ||||||
|                     "429": { |  | ||||||
|                         "description": "Too Many Requests", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/i18n.Response" |  | ||||||
|                         } |  | ||||||
|                     }, |  | ||||||
|                     "500": { |  | ||||||
|                         "description": "Internal Server Error", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/i18n.Response" |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "/api/v1/batch/photos/approve": { |         "/api/v1/batch/photos/approve": { | ||||||
|             "post": { |             "post": { | ||||||
|                 "consumes": [ |                 "consumes": [ | ||||||
| @@ -1430,6 +1360,76 @@ | |||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|         }, |         }, | ||||||
|  |         "/api/v1/batch/photos/edit": { | ||||||
|  |             "post": { | ||||||
|  |                 "consumes": [ | ||||||
|  |                     "application/json" | ||||||
|  |                 ], | ||||||
|  |                 "produces": [ | ||||||
|  |                     "application/json" | ||||||
|  |                 ], | ||||||
|  |                 "tags": [ | ||||||
|  |                     "Photos" | ||||||
|  |                 ], | ||||||
|  |                 "summary": "returns the metadata of multiple pictures so that it can be edited", | ||||||
|  |                 "operationId": "BatchPhotosEdit", | ||||||
|  |                 "parameters": [ | ||||||
|  |                     { | ||||||
|  |                         "description": "photos selection and values", | ||||||
|  |                         "name": "photos", | ||||||
|  |                         "in": "body", | ||||||
|  |                         "required": true, | ||||||
|  |                         "schema": { | ||||||
|  |                             "$ref": "#/definitions/batch.PhotosRequest" | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 ], | ||||||
|  |                 "responses": { | ||||||
|  |                     "200": { | ||||||
|  |                         "description": "OK", | ||||||
|  |                         "schema": { | ||||||
|  |                             "$ref": "#/definitions/batch.PhotosForm" | ||||||
|  |                         } | ||||||
|  |                     }, | ||||||
|  |                     "400": { | ||||||
|  |                         "description": "Bad Request", | ||||||
|  |                         "schema": { | ||||||
|  |                             "$ref": "#/definitions/i18n.Response" | ||||||
|  |                         } | ||||||
|  |                     }, | ||||||
|  |                     "401": { | ||||||
|  |                         "description": "Unauthorized", | ||||||
|  |                         "schema": { | ||||||
|  |                             "$ref": "#/definitions/i18n.Response" | ||||||
|  |                         } | ||||||
|  |                     }, | ||||||
|  |                     "403": { | ||||||
|  |                         "description": "Forbidden", | ||||||
|  |                         "schema": { | ||||||
|  |                             "$ref": "#/definitions/i18n.Response" | ||||||
|  |                         } | ||||||
|  |                     }, | ||||||
|  |                     "404": { | ||||||
|  |                         "description": "Not Found", | ||||||
|  |                         "schema": { | ||||||
|  |                             "$ref": "#/definitions/i18n.Response" | ||||||
|  |                         } | ||||||
|  |                     }, | ||||||
|  |                     "429": { | ||||||
|  |                         "description": "Too Many Requests", | ||||||
|  |                         "schema": { | ||||||
|  |                             "$ref": "#/definitions/i18n.Response" | ||||||
|  |                         } | ||||||
|  |                     }, | ||||||
|  |                     "500": { | ||||||
|  |                         "description": "Internal Server Error", | ||||||
|  |                         "schema": { | ||||||
|  |                             "$ref": "#/definitions/i18n.Response" | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|         "/api/v1/batch/photos/private": { |         "/api/v1/batch/photos/private": { | ||||||
|             "post": { |             "post": { | ||||||
|                 "consumes": [ |                 "consumes": [ | ||||||
| @@ -5170,14 +5170,16 @@ | |||||||
|         "batch.Action": { |         "batch.Action": { | ||||||
|             "type": "string", |             "type": "string", | ||||||
|             "enum": [ |             "enum": [ | ||||||
|                 "remove", |                 "none", | ||||||
|                 "keep", |                 "update", | ||||||
|                 "change" |                 "add", | ||||||
|  |                 "remove" | ||||||
|             ], |             ], | ||||||
|             "x-enum-varnames": [ |             "x-enum-varnames": [ | ||||||
|                 "ActionRemove", |                 "ActionNone", | ||||||
|                 "ActionKeep", |                 "ActionUpdate", | ||||||
|                 "ActionChange" |                 "ActionAdd", | ||||||
|  |                 "ActionRemove" | ||||||
|             ] |             ] | ||||||
|         }, |         }, | ||||||
|         "batch.Bool": { |         "batch.Bool": { | ||||||
| @@ -5186,7 +5188,7 @@ | |||||||
|                 "action": { |                 "action": { | ||||||
|                     "$ref": "#/definitions/batch.Action" |                     "$ref": "#/definitions/batch.Action" | ||||||
|                 }, |                 }, | ||||||
|                 "matches": { |                 "mixed": { | ||||||
|                     "type": "boolean" |                     "type": "boolean" | ||||||
|                 }, |                 }, | ||||||
|                 "value": { |                 "value": { | ||||||
| @@ -5200,7 +5202,7 @@ | |||||||
|                 "action": { |                 "action": { | ||||||
|                     "$ref": "#/definitions/batch.Action" |                     "$ref": "#/definitions/batch.Action" | ||||||
|                 }, |                 }, | ||||||
|                 "matches": { |                 "mixed": { | ||||||
|                     "type": "boolean" |                     "type": "boolean" | ||||||
|                 }, |                 }, | ||||||
|                 "value": { |                 "value": { | ||||||
| @@ -5214,7 +5216,7 @@ | |||||||
|                 "action": { |                 "action": { | ||||||
|                     "$ref": "#/definitions/batch.Action" |                     "$ref": "#/definitions/batch.Action" | ||||||
|                 }, |                 }, | ||||||
|                 "matches": { |                 "mixed": { | ||||||
|                     "type": "boolean" |                     "type": "boolean" | ||||||
|                 }, |                 }, | ||||||
|                 "value": { |                 "value": { | ||||||
| @@ -5228,7 +5230,7 @@ | |||||||
|                 "action": { |                 "action": { | ||||||
|                     "$ref": "#/definitions/batch.Action" |                     "$ref": "#/definitions/batch.Action" | ||||||
|                 }, |                 }, | ||||||
|                 "matches": { |                 "mixed": { | ||||||
|                     "type": "boolean" |                     "type": "boolean" | ||||||
|                 }, |                 }, | ||||||
|                 "value": { |                 "value": { | ||||||
| @@ -5236,30 +5238,39 @@ | |||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|         }, |         }, | ||||||
|         "batch.PhotoForm": { |         "batch.PhotosForm": { | ||||||
|             "type": "object", |             "type": "object", | ||||||
|             "properties": { |             "properties": { | ||||||
|                 "Altitude": { |                 "Altitude": { | ||||||
|                     "$ref": "#/definitions/batch.Int" |                     "$ref": "#/definitions/batch.Int" | ||||||
|                 }, |                 }, | ||||||
|                 "Artist": { |  | ||||||
|                     "$ref": "#/definitions/batch.String" |  | ||||||
|                 }, |  | ||||||
|                 "CameraID": { |                 "CameraID": { | ||||||
|                     "$ref": "#/definitions/batch.UInt" |                     "$ref": "#/definitions/batch.UInt" | ||||||
|                 }, |                 }, | ||||||
|                 "Caption": { |                 "Caption": { | ||||||
|                     "$ref": "#/definitions/batch.String" |                     "$ref": "#/definitions/batch.String" | ||||||
|                 }, |                 }, | ||||||
|                 "Copyright": { |  | ||||||
|                     "$ref": "#/definitions/batch.String" |  | ||||||
|                 }, |  | ||||||
|                 "Country": { |                 "Country": { | ||||||
|                     "$ref": "#/definitions/batch.String" |                     "$ref": "#/definitions/batch.String" | ||||||
|                 }, |                 }, | ||||||
|                 "Day": { |                 "Day": { | ||||||
|                     "$ref": "#/definitions/batch.Int" |                     "$ref": "#/definitions/batch.Int" | ||||||
|                 }, |                 }, | ||||||
|  |                 "DetailsArtist": { | ||||||
|  |                     "$ref": "#/definitions/batch.String" | ||||||
|  |                 }, | ||||||
|  |                 "DetailsCopyright": { | ||||||
|  |                     "$ref": "#/definitions/batch.String" | ||||||
|  |                 }, | ||||||
|  |                 "DetailsKeywords": { | ||||||
|  |                     "$ref": "#/definitions/batch.String" | ||||||
|  |                 }, | ||||||
|  |                 "DetailsLicense": { | ||||||
|  |                     "$ref": "#/definitions/batch.String" | ||||||
|  |                 }, | ||||||
|  |                 "DetailsSubject": { | ||||||
|  |                     "$ref": "#/definitions/batch.String" | ||||||
|  |                 }, | ||||||
|                 "Exposure": { |                 "Exposure": { | ||||||
|                     "$ref": "#/definitions/batch.String" |                     "$ref": "#/definitions/batch.String" | ||||||
|                 }, |                 }, | ||||||
| @@ -5281,9 +5292,6 @@ | |||||||
|                 "LensID": { |                 "LensID": { | ||||||
|                     "$ref": "#/definitions/batch.UInt" |                     "$ref": "#/definitions/batch.UInt" | ||||||
|                 }, |                 }, | ||||||
|                 "License": { |  | ||||||
|                     "$ref": "#/definitions/batch.String" |  | ||||||
|                 }, |  | ||||||
|                 "Lng": { |                 "Lng": { | ||||||
|                     "$ref": "#/definitions/batch.Float64" |                     "$ref": "#/definitions/batch.Float64" | ||||||
|                 }, |                 }, | ||||||
| @@ -5299,9 +5307,6 @@ | |||||||
|                 "Scan": { |                 "Scan": { | ||||||
|                     "$ref": "#/definitions/batch.Bool" |                     "$ref": "#/definitions/batch.Bool" | ||||||
|                 }, |                 }, | ||||||
|                 "Subject": { |  | ||||||
|                     "$ref": "#/definitions/batch.String" |  | ||||||
|                 }, |  | ||||||
|                 "TakenAt": { |                 "TakenAt": { | ||||||
|                     "type": "string" |                     "type": "string" | ||||||
|                 }, |                 }, | ||||||
| @@ -5322,13 +5327,33 @@ | |||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|         }, |         }, | ||||||
|  |         "batch.PhotosRequest": { | ||||||
|  |             "type": "object", | ||||||
|  |             "properties": { | ||||||
|  |                 "filter": { | ||||||
|  |                     "type": "string" | ||||||
|  |                 }, | ||||||
|  |                 "photos": { | ||||||
|  |                     "type": "array", | ||||||
|  |                     "items": { | ||||||
|  |                         "type": "string" | ||||||
|  |                     } | ||||||
|  |                 }, | ||||||
|  |                 "return": { | ||||||
|  |                     "type": "boolean" | ||||||
|  |                 }, | ||||||
|  |                 "values": { | ||||||
|  |                     "$ref": "#/definitions/batch.PhotosForm" | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|         "batch.String": { |         "batch.String": { | ||||||
|             "type": "object", |             "type": "object", | ||||||
|             "properties": { |             "properties": { | ||||||
|                 "action": { |                 "action": { | ||||||
|                     "$ref": "#/definitions/batch.Action" |                     "$ref": "#/definitions/batch.Action" | ||||||
|                 }, |                 }, | ||||||
|                 "matches": { |                 "mixed": { | ||||||
|                     "type": "boolean" |                     "type": "boolean" | ||||||
|                 }, |                 }, | ||||||
|                 "value": { |                 "value": { | ||||||
| @@ -5342,7 +5367,7 @@ | |||||||
|                 "action": { |                 "action": { | ||||||
|                     "$ref": "#/definitions/batch.Action" |                     "$ref": "#/definitions/batch.Action" | ||||||
|                 }, |                 }, | ||||||
|                 "matches": { |                 "mixed": { | ||||||
|                     "type": "boolean" |                     "type": "boolean" | ||||||
|                 }, |                 }, | ||||||
|                 "value": { |                 "value": { | ||||||
| @@ -7215,6 +7240,9 @@ | |||||||
|                         "type": "string" |                         "type": "string" | ||||||
|                     } |                     } | ||||||
|                 }, |                 }, | ||||||
|  |                 "filter": { | ||||||
|  |                     "type": "string" | ||||||
|  |                 }, | ||||||
|                 "labels": { |                 "labels": { | ||||||
|                     "type": "array", |                     "type": "array", | ||||||
|                     "items": { |                     "items": { | ||||||
| @@ -7699,6 +7727,22 @@ | |||||||
|                 "DeletedAt": { |                 "DeletedAt": { | ||||||
|                     "type": "string" |                     "type": "string" | ||||||
|                 }, |                 }, | ||||||
|  |                 "DetailsArtist": { | ||||||
|  |                     "type": "string" | ||||||
|  |                 }, | ||||||
|  |                 "DetailsCopyright": { | ||||||
|  |                     "type": "string" | ||||||
|  |                 }, | ||||||
|  |                 "DetailsKeywords": { | ||||||
|  |                     "description": "Additional information from the details table.", | ||||||
|  |                     "type": "string" | ||||||
|  |                 }, | ||||||
|  |                 "DetailsLicense": { | ||||||
|  |                     "type": "string" | ||||||
|  |                 }, | ||||||
|  |                 "DetailsSubject": { | ||||||
|  |                     "type": "string" | ||||||
|  |                 }, | ||||||
|                 "DocumentID": { |                 "DocumentID": { | ||||||
|                     "type": "string" |                     "type": "string" | ||||||
|                 }, |                 }, | ||||||
| @@ -7730,6 +7774,7 @@ | |||||||
|                     "type": "string" |                     "type": "string" | ||||||
|                 }, |                 }, | ||||||
|                 "Files": { |                 "Files": { | ||||||
|  |                     "description": "List of files if search results are merged.", | ||||||
|                     "type": "array", |                     "type": "array", | ||||||
|                     "items": { |                     "items": { | ||||||
|                         "$ref": "#/definitions/entity.File" |                         "$ref": "#/definitions/entity.File" | ||||||
| @@ -7923,6 +7968,22 @@ | |||||||
|         "time.Duration": { |         "time.Duration": { | ||||||
|             "type": "integer", |             "type": "integer", | ||||||
|             "enum": [ |             "enum": [ | ||||||
|  |                 -9223372036854775808, | ||||||
|  |                 9223372036854775807, | ||||||
|  |                 1, | ||||||
|  |                 1000, | ||||||
|  |                 1000000, | ||||||
|  |                 1000000000, | ||||||
|  |                 60000000000, | ||||||
|  |                 3600000000000, | ||||||
|  |                 -9223372036854775808, | ||||||
|  |                 9223372036854775807, | ||||||
|  |                 1, | ||||||
|  |                 1000, | ||||||
|  |                 1000000, | ||||||
|  |                 1000000000, | ||||||
|  |                 60000000000, | ||||||
|  |                 3600000000000, | ||||||
|                 -9223372036854775808, |                 -9223372036854775808, | ||||||
|                 9223372036854775807, |                 9223372036854775807, | ||||||
|                 1, |                 1, | ||||||
| @@ -7933,6 +7994,22 @@ | |||||||
|                 3600000000000 |                 3600000000000 | ||||||
|             ], |             ], | ||||||
|             "x-enum-varnames": [ |             "x-enum-varnames": [ | ||||||
|  |                 "minDuration", | ||||||
|  |                 "maxDuration", | ||||||
|  |                 "Nanosecond", | ||||||
|  |                 "Microsecond", | ||||||
|  |                 "Millisecond", | ||||||
|  |                 "Second", | ||||||
|  |                 "Minute", | ||||||
|  |                 "Hour", | ||||||
|  |                 "minDuration", | ||||||
|  |                 "maxDuration", | ||||||
|  |                 "Nanosecond", | ||||||
|  |                 "Microsecond", | ||||||
|  |                 "Millisecond", | ||||||
|  |                 "Second", | ||||||
|  |                 "Minute", | ||||||
|  |                 "Hour", | ||||||
|                 "minDuration", |                 "minDuration", | ||||||
|                 "maxDuration", |                 "maxDuration", | ||||||
|                 "Nanosecond", |                 "Nanosecond", | ||||||
|   | |||||||
| @@ -75,3 +75,10 @@ var DetailsFixtures = DetailsMap{ | |||||||
| 		LicenseSrc:   "manual", | 		LicenseSrc:   "manual", | ||||||
| 	}, | 	}, | ||||||
| } | } | ||||||
|  |  | ||||||
|  | // CreateDetailsFixtures inserts known entities into the database for testing. | ||||||
|  | func CreateDetailsFixtures() { | ||||||
|  | 	for _, entity := range DetailsFixtures { | ||||||
|  | 		UnscopedDb().Create(&entity) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|   | |||||||
| @@ -32,5 +32,7 @@ func ResetTestFixtures() { | |||||||
|  |  | ||||||
| 	CreateTestFixtures() | 	CreateTestFixtures() | ||||||
|  |  | ||||||
|  | 	File{}.RegenerateIndex() | ||||||
|  |  | ||||||
| 	log.Debugf("migrate: recreated test fixtures [%s]", time.Since(start)) | 	log.Debugf("migrate: recreated test fixtures [%s]", time.Since(start)) | ||||||
| } | } | ||||||
|   | |||||||
| @@ -10,6 +10,7 @@ func CreateTestFixtures() { | |||||||
| 	CreateCameraFixtures() | 	CreateCameraFixtures() | ||||||
| 	CreateCountryFixtures() | 	CreateCountryFixtures() | ||||||
| 	CreatePhotoFixtures() | 	CreatePhotoFixtures() | ||||||
|  | 	CreateDetailsFixtures() | ||||||
| 	CreateAlbumFixtures() | 	CreateAlbumFixtures() | ||||||
| 	CreateServiceFixtures() | 	CreateServiceFixtures() | ||||||
| 	CreateLinkFixtures() | 	CreateLinkFixtures() | ||||||
|   | |||||||
							
								
								
									
										104
									
								
								internal/entity/search/batch.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										104
									
								
								internal/entity/search/batch.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,104 @@ | |||||||
|  | package search | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"strings" | ||||||
|  | 	"time" | ||||||
|  |  | ||||||
|  | 	"github.com/photoprism/photoprism/internal/entity" | ||||||
|  | 	"github.com/photoprism/photoprism/internal/form" | ||||||
|  | 	"github.com/photoprism/photoprism/pkg/txt" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | // BatchResult represents a photo geo search result. | ||||||
|  | type BatchResult struct { | ||||||
|  | 	ID               uint          `json:"-" select:"photos.id"` | ||||||
|  | 	CompositeID      string        `json:"ID,omitempty" select:"files.photo_id AS composite_id"` | ||||||
|  | 	UUID             string        `json:"DocumentID,omitempty" select:"photos.uuid"` | ||||||
|  | 	PhotoUID         string        `json:"UID" select:"photos.photo_uid"` | ||||||
|  | 	PhotoType        string        `json:"Type" select:"photos.photo_type"` | ||||||
|  | 	TypeSrc          string        `json:"TypeSrc" select:"photos.taken_src"` | ||||||
|  | 	PhotoTitle       string        `json:"Title" select:"photos.photo_title"` | ||||||
|  | 	PhotoCaption     string        `json:"Caption,omitempty" select:"photos.photo_caption"` | ||||||
|  | 	TakenAt          time.Time     `json:"TakenAt" select:"photos.taken_at"` | ||||||
|  | 	TakenAtLocal     time.Time     `json:"TakenAtLocal" select:"photos.taken_at_local"` | ||||||
|  | 	TimeZone         string        `json:"TimeZone" select:"photos.time_zone"` | ||||||
|  | 	PhotoYear        int           `json:"Year" select:"photos.photo_year"` | ||||||
|  | 	PhotoMonth       int           `json:"Month" select:"photos.photo_month"` | ||||||
|  | 	PhotoDay         int           `json:"Day" select:"photos.photo_day"` | ||||||
|  | 	PhotoCountry     string        `json:"Country" select:"photos.photo_country"` | ||||||
|  | 	PhotoStack       int8          `json:"Stack" select:"photos.photo_stack"` | ||||||
|  | 	PhotoFavorite    bool          `json:"Favorite" select:"photos.photo_favorite"` | ||||||
|  | 	PhotoPrivate     bool          `json:"Private" select:"photos.photo_private"` | ||||||
|  | 	PhotoIso         int           `json:"Iso" select:"photos.photo_iso"` | ||||||
|  | 	PhotoFocalLength int           `json:"FocalLength" select:"photos.photo_focal_length"` | ||||||
|  | 	PhotoFNumber     float32       `json:"FNumber" select:"photos.photo_f_number"` | ||||||
|  | 	PhotoExposure    string        `json:"Exposure" select:"photos.photo_exposure"` | ||||||
|  | 	PhotoFaces       int           `json:"Faces,omitempty" select:"photos.photo_faces"` | ||||||
|  | 	PhotoQuality     int           `json:"Quality" select:"photos.photo_quality"` | ||||||
|  | 	PhotoResolution  int           `json:"Resolution" select:"photos.photo_resolution"` | ||||||
|  | 	PhotoDuration    time.Duration `json:"Duration,omitempty" select:"photos.photo_duration"` | ||||||
|  | 	PhotoColor       int16         `json:"Color" select:"photos.photo_color"` | ||||||
|  | 	PhotoScan        bool          `json:"Scan" select:"photos.photo_scan"` | ||||||
|  | 	PhotoPanorama    bool          `json:"Panorama" select:"photos.photo_panorama"` | ||||||
|  | 	CameraID         uint          `json:"CameraID" select:"photos.camera_id"` // Camera | ||||||
|  | 	CameraSrc        string        `json:"CameraSrc,omitempty" select:"photos.camera_src"` | ||||||
|  | 	CameraSerial     string        `json:"CameraSerial,omitempty" select:"photos.camera_serial"` | ||||||
|  | 	CameraMake       string        `json:"CameraMake,omitempty" select:"cameras.camera_make"` | ||||||
|  | 	CameraModel      string        `json:"CameraModel,omitempty" select:"cameras.camera_model"` | ||||||
|  | 	CameraType       string        `json:"CameraType,omitempty" select:"cameras.camera_type"` | ||||||
|  | 	LensID           uint          `json:"LensID" select:"photos.lens_id"` // Lens | ||||||
|  | 	LensMake         string        `json:"LensMake,omitempty" select:"lenses.lens_model"` | ||||||
|  | 	LensModel        string        `json:"LensModel,omitempty" select:"lenses.lens_make"` | ||||||
|  | 	PhotoAltitude    int           `json:"Altitude,omitempty" select:"photos.photo_altitude"` | ||||||
|  | 	PhotoLat         float64       `json:"Lat" select:"photos.photo_lat"` | ||||||
|  | 	PhotoLng         float64       `json:"Lng" select:"photos.photo_lng"` | ||||||
|  |  | ||||||
|  | 	FileID          uint          `json:"-" select:"files.id AS file_id"` // File | ||||||
|  | 	FileUID         string        `json:"FileUID" select:"files.file_uid"` | ||||||
|  | 	FileRoot        string        `json:"FileRoot" select:"files.file_root"` | ||||||
|  | 	FileName        string        `json:"FileName" select:"files.file_name"` | ||||||
|  | 	OriginalName    string        `json:"OriginalName" select:"files.original_name"` | ||||||
|  | 	FileHash        string        `json:"Hash" select:"files.file_hash"` | ||||||
|  | 	FileWidth       int           `json:"Width" select:"files.file_width"` | ||||||
|  | 	FileHeight      int           `json:"Height" select:"files.file_height"` | ||||||
|  | 	FilePortrait    bool          `json:"Portrait" select:"files.file_portrait"` | ||||||
|  | 	FilePrimary     bool          `json:"-" select:"files.file_primary"` | ||||||
|  | 	FileSidecar     bool          `json:"-" select:"files.file_sidecar"` | ||||||
|  | 	FileMissing     bool          `json:"-" select:"files.file_missing"` | ||||||
|  | 	FileVideo       bool          `json:"-" select:"files.file_video"` | ||||||
|  | 	FileDuration    time.Duration `json:"-" select:"files.file_duration"` | ||||||
|  | 	FileFPS         float64       `json:"-" select:"files.file_fps"` | ||||||
|  | 	FileFrames      int           `json:"-" select:"files.file_frames"` | ||||||
|  | 	FilePages       int           `json:"-" select:"files.file_pages"` | ||||||
|  | 	FileCodec       string        `json:"-" select:"files.file_codec"` | ||||||
|  | 	FileType        string        `json:"-" select:"files.file_type"` | ||||||
|  | 	MediaType       string        `json:"-" select:"files.media_type"` | ||||||
|  | 	FileMime        string        `json:"-" select:"files.file_mime"` | ||||||
|  | 	FileSize        int64         `json:"-" select:"files.file_size"` | ||||||
|  | 	FileOrientation int           `json:"-" select:"files.file_orientation"` | ||||||
|  | 	FileProjection  string        `json:"-" select:"files.file_projection"` | ||||||
|  | 	FileAspectRatio float32       `json:"-" select:"files.file_aspect_ratio"` | ||||||
|  |  | ||||||
|  | 	DetailsKeywords  string `json:"DetailsKeywords" select:"details.keywords AS details_keywords"` | ||||||
|  | 	DetailsSubject   string `json:"DetailsSubject" select:"details.subject AS details_subject"` | ||||||
|  | 	DetailsArtist    string `json:"DetailsArtist" select:"details.artist AS details_artist"` | ||||||
|  | 	DetailsCopyright string `json:"DetailsCopyright" select:"details.copyright AS details_copyright"` | ||||||
|  | 	DetailsLicense   string `json:"DetailsLicense" select:"details.license AS details_license"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // BatchCols contains the result column names necessary for the photo viewer. | ||||||
|  | var BatchCols = SelectString(BatchResult{}, SelectCols(BatchResult{}, []string{"*"})) | ||||||
|  |  | ||||||
|  | // BatchPhotos finds PhotoResults based on the search form without checking rights or permissions. | ||||||
|  | func BatchPhotos(uids []string, sess *entity.Session) (results PhotoResults, count int, err error) { | ||||||
|  | 	frm := form.SearchPhotos{ | ||||||
|  | 		UID:     strings.Join(uids, txt.Or), | ||||||
|  | 		Count:   MaxResults, | ||||||
|  | 		Offset:  0, | ||||||
|  | 		Face:    "", | ||||||
|  | 		Merged:  true, | ||||||
|  | 		Details: true, | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return searchPhotos(frm, sess, BatchCols) | ||||||
|  | } | ||||||
							
								
								
									
										22
									
								
								internal/entity/search/batch_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								internal/entity/search/batch_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | |||||||
|  | package search | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"testing" | ||||||
|  |  | ||||||
|  | 	"github.com/stretchr/testify/assert" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | func TestBatchPhotos(t *testing.T) { | ||||||
|  | 	t.Run("Success", func(t *testing.T) { | ||||||
|  | 		uids := []string{"ps6sg6be2lvl0yh7", "ps6sg6be2lvl0yh8"} | ||||||
|  |  | ||||||
|  | 		photos, count, err := BatchPhotos(uids, nil) | ||||||
|  |  | ||||||
|  | 		assert.Equal(t, 2, count) | ||||||
|  | 		assert.Len(t, photos, 2) | ||||||
|  |  | ||||||
|  | 		if err != nil { | ||||||
|  | 			t.Fatal(err) | ||||||
|  | 		} | ||||||
|  | 	}) | ||||||
|  | } | ||||||
| @@ -81,8 +81,14 @@ func searchPhotos(frm form.SearchPhotos, sess *entity.Session, resultCols string | |||||||
|  |  | ||||||
| 	// Specify table names and joins. | 	// Specify table names and joins. | ||||||
| 	s := UnscopedDb().Table(entity.File{}.TableName()).Select(resultCols). | 	s := UnscopedDb().Table(entity.File{}.TableName()).Select(resultCols). | ||||||
| 		Joins("JOIN photos ON files.photo_id = photos.id AND files.media_id IS NOT NULL"). | 		Joins("JOIN photos ON files.photo_id = photos.id AND files.media_id IS NOT NULL") | ||||||
| 		Joins("LEFT JOIN cameras ON photos.camera_id = cameras.id"). |  | ||||||
|  | 	// Include additional columns from details table? | ||||||
|  | 	if frm.Details { | ||||||
|  | 		s = s.Joins("JOIN details ON details.photo_id = files.photo_id") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	s = s.Joins("LEFT JOIN cameras ON photos.camera_id = cameras.id"). | ||||||
| 		Joins("LEFT JOIN lenses ON photos.lens_id = lenses.id"). | 		Joins("LEFT JOIN lenses ON photos.lens_id = lenses.id"). | ||||||
| 		Joins("LEFT JOIN places ON photos.place_id = places.id") | 		Joins("LEFT JOIN places ON photos.place_id = places.id") | ||||||
|  |  | ||||||
|   | |||||||
| @@ -106,7 +106,15 @@ type Photo struct { | |||||||
| 	CheckedAt        time.Time     `json:"CheckedAt,omitempty" select:"photos.checked_at"` | 	CheckedAt        time.Time     `json:"CheckedAt,omitempty" select:"photos.checked_at"` | ||||||
| 	DeletedAt        *time.Time    `json:"DeletedAt,omitempty" select:"photos.deleted_at"` | 	DeletedAt        *time.Time    `json:"DeletedAt,omitempty" select:"photos.deleted_at"` | ||||||
|  |  | ||||||
| 	Files []entity.File `json:"Files"` | 	// Additional information from the details table. | ||||||
|  | 	DetailsKeywords  string `json:"DetailsKeywords,omitempty" select:"-"` | ||||||
|  | 	DetailsSubject   string `json:"DetailsSubject,omitempty" select:"-"` | ||||||
|  | 	DetailsArtist    string `json:"DetailsArtist,omitempty" select:"-"` | ||||||
|  | 	DetailsCopyright string `json:"DetailsCopyright,omitempty" select:"-"` | ||||||
|  | 	DetailsLicense   string `json:"DetailsLicense,omitempty" select:"-"` | ||||||
|  |  | ||||||
|  | 	// List of files if search results are merged. | ||||||
|  | 	Files []entity.File `json:"Files" select:"-"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // GetID returns the numeric entity ID. | // GetID returns the numeric entity ID. | ||||||
|   | |||||||
| @@ -3,7 +3,8 @@ package batch | |||||||
| type Action = string | type Action = string | ||||||
|  |  | ||||||
| const ( | const ( | ||||||
|  | 	ActionNone   Action = "none" | ||||||
|  | 	ActionUpdate Action = "update" | ||||||
|  | 	ActionAdd    Action = "add" | ||||||
| 	ActionRemove Action = "remove" | 	ActionRemove Action = "remove" | ||||||
| 	ActionKeep   Action = "keep" |  | ||||||
| 	ActionChange Action = "change" |  | ||||||
| ) | ) | ||||||
|   | |||||||
| @@ -1,11 +1,11 @@ | |||||||
| package batch | package batch | ||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"github.com/photoprism/photoprism/internal/entity" | 	"github.com/photoprism/photoprism/internal/entity/search" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| // PhotoForm represents photo batch edit form values. | // PhotosForm represents photo batch edit form values. | ||||||
| type PhotoForm struct { | type PhotosForm struct { | ||||||
| 	PhotoType        String  `json:"Type"` | 	PhotoType        String  `json:"Type"` | ||||||
| 	PhotoTitle       String  `json:"Title"` | 	PhotoTitle       String  `json:"Title"` | ||||||
| 	PhotoCaption     String  `json:"Caption"` | 	PhotoCaption     String  `json:"Caption"` | ||||||
| @@ -30,207 +30,223 @@ type PhotoForm struct { | |||||||
| 	CameraID         UInt    `json:"CameraID"` | 	CameraID         UInt    `json:"CameraID"` | ||||||
| 	LensID           UInt    `json:"LensID"` | 	LensID           UInt    `json:"LensID"` | ||||||
|  |  | ||||||
| 	DetailsSubject   String `json:"Subject"` | 	DetailsKeywords  String `json:"DetailsKeywords"` | ||||||
| 	DetailsArtist    String `json:"Artist"` | 	DetailsSubject   String `json:"DetailsSubject"` | ||||||
| 	DetailsCopyright String `json:"Copyright"` | 	DetailsArtist    String `json:"DetailsArtist"` | ||||||
| 	DetailsLicense   String `json:"License"` | 	DetailsCopyright String `json:"DetailsCopyright"` | ||||||
|  | 	DetailsLicense   String `json:"DetailsLicense"` | ||||||
| } | } | ||||||
|  |  | ||||||
| func NewPhotoForm(photos entity.Photos) *PhotoForm { | func NewPhotosForm(photos search.PhotoResults) *PhotosForm { | ||||||
| 	frm := &PhotoForm{} | 	frm := &PhotosForm{} | ||||||
|  |  | ||||||
| 	for _, photo := range photos { | 	for i, photo := range photos { | ||||||
| 		if photo.PhotoType != "" && frm.PhotoType.Value == "" { | 		if i == 0 { | ||||||
| 			frm.PhotoType.Value = photo.PhotoType | 			frm.PhotoType.Value = photo.PhotoType | ||||||
| 			frm.PhotoType.Matches = true |  | ||||||
| 		} else if photo.PhotoType != frm.PhotoType.Value { | 		} else if photo.PhotoType != frm.PhotoType.Value { | ||||||
| 			frm.PhotoType.Matches = false | 			frm.PhotoType.Mixed = true | ||||||
|  | 			frm.PhotoType.Value = "" | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if photo.PhotoTitle != "" && frm.PhotoTitle.Value == "" { | 		if i == 0 { | ||||||
| 			frm.PhotoTitle.Value = photo.PhotoTitle | 			frm.PhotoTitle.Value = photo.PhotoTitle | ||||||
| 			frm.PhotoTitle.Matches = true |  | ||||||
| 		} else if photo.PhotoTitle != frm.PhotoTitle.Value { | 		} else if photo.PhotoTitle != frm.PhotoTitle.Value { | ||||||
| 			frm.PhotoTitle.Matches = false | 			frm.PhotoTitle.Mixed = true | ||||||
|  | 			frm.PhotoTitle.Value = "" | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if photo.PhotoCaption != "" && frm.PhotoCaption.Value == "" { | 		if i == 0 { | ||||||
| 			frm.PhotoCaption.Value = photo.PhotoCaption | 			frm.PhotoCaption.Value = photo.PhotoCaption | ||||||
| 			frm.PhotoTitle.Matches = true |  | ||||||
| 		} else if photo.PhotoCaption != frm.PhotoCaption.Value { | 		} else if photo.PhotoCaption != frm.PhotoCaption.Value { | ||||||
| 			frm.PhotoCaption.Matches = false | 			frm.PhotoCaption.Mixed = true | ||||||
|  | 			frm.PhotoCaption.Value = "" | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if !photo.TakenAt.IsZero() && frm.TakenAt.Value.IsZero() { | 		if i == 0 { | ||||||
| 			frm.TakenAt.Value = photo.TakenAt | 			frm.TakenAt.Value = photo.TakenAt | ||||||
| 			frm.TakenAt.Matches = true |  | ||||||
| 		} else if photo.TakenAt != frm.TakenAt.Value { | 		} else if photo.TakenAt != frm.TakenAt.Value { | ||||||
| 			frm.TakenAt.Matches = false | 			frm.TakenAt.Mixed = true | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if !photo.TakenAtLocal.IsZero() && frm.TakenAtLocal.Value.IsZero() { | 		if i == 0 { | ||||||
| 			frm.TakenAtLocal.Value = photo.TakenAtLocal | 			frm.TakenAtLocal.Value = photo.TakenAtLocal | ||||||
| 			frm.TakenAtLocal.Matches = true |  | ||||||
| 		} else if photo.TakenAtLocal != frm.TakenAtLocal.Value { | 		} else if photo.TakenAtLocal != frm.TakenAtLocal.Value { | ||||||
| 			frm.TakenAtLocal.Matches = false | 			frm.TakenAtLocal.Mixed = true | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if photo.PhotoDay > 0 && frm.PhotoDay.Value == 0 { | 		if i == 0 { | ||||||
| 			frm.PhotoDay.Value = photo.PhotoDay | 			frm.PhotoDay.Value = photo.PhotoDay | ||||||
| 			frm.PhotoDay.Matches = true |  | ||||||
| 		} else if photo.PhotoDay != frm.PhotoDay.Value { | 		} else if photo.PhotoDay != frm.PhotoDay.Value { | ||||||
| 			frm.PhotoDay.Matches = false | 			frm.PhotoDay.Mixed = true | ||||||
|  | 			frm.PhotoDay.Value = 0 | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if photo.PhotoMonth > 0 && frm.PhotoMonth.Value == 0 { | 		if i == 0 { | ||||||
| 			frm.PhotoMonth.Value = photo.PhotoMonth | 			frm.PhotoMonth.Value = photo.PhotoMonth | ||||||
| 			frm.PhotoMonth.Matches = true |  | ||||||
| 		} else if photo.PhotoMonth != frm.PhotoMonth.Value { | 		} else if photo.PhotoMonth != frm.PhotoMonth.Value { | ||||||
| 			frm.PhotoMonth.Matches = false | 			frm.PhotoMonth.Mixed = true | ||||||
|  | 			frm.PhotoMonth.Value = 0 | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if photo.PhotoYear > 0 && frm.PhotoYear.Value == 0 { | 		if i == 0 { | ||||||
| 			frm.PhotoYear.Value = photo.PhotoYear | 			frm.PhotoYear.Value = photo.PhotoYear | ||||||
| 			frm.PhotoYear.Matches = true |  | ||||||
| 		} else if photo.PhotoYear != frm.PhotoYear.Value { | 		} else if photo.PhotoYear != frm.PhotoYear.Value { | ||||||
| 			frm.PhotoYear.Matches = false | 			frm.PhotoYear.Mixed = true | ||||||
|  | 			frm.PhotoYear.Value = 0 | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if photo.TimeZone != "" && frm.TimeZone.Value == "" { | 		if i == 0 { | ||||||
| 			frm.TimeZone.Value = photo.TimeZone | 			frm.TimeZone.Value = photo.TimeZone | ||||||
| 			frm.TimeZone.Matches = true |  | ||||||
| 		} else if photo.TimeZone != frm.TimeZone.Value { | 		} else if photo.TimeZone != frm.TimeZone.Value { | ||||||
| 			frm.TimeZone.Matches = false | 			frm.TimeZone.Mixed = true | ||||||
|  | 			frm.TimeZone.Value = "Local" | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if photo.PhotoCountry != "" && frm.PhotoCountry.Value == "" { | 		if i == 0 { | ||||||
| 			frm.PhotoCountry.Value = photo.PhotoCountry | 			frm.PhotoCountry.Value = photo.PhotoCountry | ||||||
| 			frm.PhotoCountry.Matches = true |  | ||||||
| 		} else if photo.PhotoCountry != frm.PhotoCountry.Value { | 		} else if photo.PhotoCountry != frm.PhotoCountry.Value { | ||||||
| 			frm.PhotoCountry.Matches = false | 			frm.PhotoCountry.Mixed = true | ||||||
|  | 			frm.PhotoCountry.Value = "zz" | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if photo.PhotoAltitude != 0 && frm.PhotoAltitude.Value == 0 { | 		if i == 0 { | ||||||
| 			frm.PhotoAltitude.Value = photo.PhotoAltitude | 			frm.PhotoAltitude.Value = photo.PhotoAltitude | ||||||
| 			frm.PhotoAltitude.Matches = true |  | ||||||
| 		} else if photo.PhotoAltitude != frm.PhotoAltitude.Value { | 		} else if photo.PhotoAltitude != frm.PhotoAltitude.Value { | ||||||
| 			frm.PhotoAltitude.Matches = false | 			frm.PhotoAltitude.Mixed = true | ||||||
|  | 			frm.PhotoAltitude.Value = 0 | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if photo.PhotoLat != 0.0 && frm.PhotoLat.Value == 0.0 { | 		if i == 0 { | ||||||
| 			frm.PhotoLat.Value = photo.PhotoLat | 			frm.PhotoLat.Value = photo.PhotoLat | ||||||
| 			frm.PhotoLat.Matches = true |  | ||||||
| 		} else if photo.PhotoLat != frm.PhotoLat.Value { | 		} else if photo.PhotoLat != frm.PhotoLat.Value { | ||||||
| 			frm.PhotoLat.Matches = false | 			frm.PhotoLat.Mixed = true | ||||||
|  | 			frm.PhotoLat.Value = 0.0 | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if photo.PhotoLng != 0.0 && frm.PhotoLng.Value == 0.0 { | 		if i == 0 { | ||||||
| 			frm.PhotoLng.Value = photo.PhotoLng | 			frm.PhotoLng.Value = photo.PhotoLng | ||||||
| 			frm.PhotoLng.Matches = true |  | ||||||
| 		} else if photo.PhotoLng != frm.PhotoLng.Value { | 		} else if photo.PhotoLng != frm.PhotoLng.Value { | ||||||
| 			frm.PhotoLng.Matches = false | 			frm.PhotoLng.Mixed = false | ||||||
|  | 			frm.PhotoLng.Value = 0.0 | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if photo.PhotoIso != 0 && frm.PhotoIso.Value == 0 { | 		if i == 0 { | ||||||
| 			frm.PhotoIso.Value = photo.PhotoIso | 			frm.PhotoIso.Value = photo.PhotoIso | ||||||
| 			frm.PhotoIso.Matches = true |  | ||||||
| 		} else if photo.PhotoIso != frm.PhotoIso.Value { | 		} else if photo.PhotoIso != frm.PhotoIso.Value { | ||||||
| 			frm.PhotoIso.Matches = false | 			frm.PhotoIso.Mixed = true | ||||||
|  | 			frm.PhotoIso.Value = 0 | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if photo.PhotoFocalLength != 0 && frm.PhotoFocalLength.Value == 0 { | 		if i == 0 { | ||||||
| 			frm.PhotoFocalLength.Value = photo.PhotoFocalLength | 			frm.PhotoFocalLength.Value = photo.PhotoFocalLength | ||||||
| 			frm.PhotoFocalLength.Matches = true |  | ||||||
| 		} else if photo.PhotoFocalLength != frm.PhotoFocalLength.Value { | 		} else if photo.PhotoFocalLength != frm.PhotoFocalLength.Value { | ||||||
| 			frm.PhotoFocalLength.Matches = false | 			frm.PhotoFocalLength.Mixed = true | ||||||
|  | 			frm.PhotoFocalLength.Value = 0 | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if photo.PhotoFNumber != 0.0 && frm.PhotoFNumber.Value == 0.0 { | 		if i == 0 { | ||||||
| 			frm.PhotoFNumber.Value = photo.PhotoFNumber | 			frm.PhotoFNumber.Value = photo.PhotoFNumber | ||||||
| 			frm.PhotoFNumber.Matches = true |  | ||||||
| 		} else if photo.PhotoFNumber != frm.PhotoFNumber.Value { | 		} else if photo.PhotoFNumber != frm.PhotoFNumber.Value { | ||||||
| 			frm.PhotoFNumber.Matches = false | 			frm.PhotoFNumber.Mixed = true | ||||||
|  | 			frm.PhotoFNumber.Value = 0 | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if photo.PhotoExposure != "" && frm.PhotoExposure.Value == "" { | 		if i == 0 { | ||||||
| 			frm.PhotoExposure.Value = photo.PhotoExposure | 			frm.PhotoExposure.Value = photo.PhotoExposure | ||||||
| 			frm.PhotoExposure.Matches = true |  | ||||||
| 		} else if photo.PhotoExposure != frm.PhotoExposure.Value { | 		} else if photo.PhotoExposure != frm.PhotoExposure.Value { | ||||||
| 			frm.PhotoExposure.Matches = false | 			frm.PhotoExposure.Mixed = true | ||||||
|  | 			frm.PhotoExposure.Value = "" | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if photo.PhotoFavorite && !frm.PhotoFavorite.Value { | 		if i == 0 { | ||||||
| 			frm.PhotoFavorite.Value = photo.PhotoFavorite | 			frm.PhotoFavorite.Value = photo.PhotoFavorite | ||||||
| 			frm.PhotoFavorite.Matches = true |  | ||||||
| 		} else if photo.PhotoFavorite != frm.PhotoFavorite.Value { | 		} else if photo.PhotoFavorite != frm.PhotoFavorite.Value { | ||||||
| 			frm.PhotoFavorite.Matches = false | 			frm.PhotoFavorite.Mixed = true | ||||||
|  | 			frm.PhotoFavorite.Value = false | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if photo.PhotoPrivate && !frm.PhotoPrivate.Value { | 		if i == 0 { | ||||||
| 			frm.PhotoPrivate.Value = photo.PhotoPrivate | 			frm.PhotoPrivate.Value = photo.PhotoPrivate | ||||||
| 			frm.PhotoPrivate.Matches = true |  | ||||||
| 		} else if photo.PhotoPrivate != frm.PhotoPrivate.Value { | 		} else if photo.PhotoPrivate != frm.PhotoPrivate.Value { | ||||||
| 			frm.PhotoPrivate.Matches = false | 			frm.PhotoPrivate.Mixed = true | ||||||
|  | 			frm.PhotoPrivate.Value = false | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if photo.PhotoScan && !frm.PhotoScan.Value { | 		if i == 0 { | ||||||
| 			frm.PhotoScan.Value = photo.PhotoScan | 			frm.PhotoScan.Value = photo.PhotoScan | ||||||
| 			frm.PhotoScan.Matches = true |  | ||||||
| 		} else if photo.PhotoScan != frm.PhotoScan.Value { | 		} else if photo.PhotoScan != frm.PhotoScan.Value { | ||||||
| 			frm.PhotoScan.Matches = false | 			frm.PhotoScan.Mixed = true | ||||||
|  | 			frm.PhotoScan.Value = false | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if photo.PhotoPanorama && !frm.PhotoPanorama.Value { | 		if i == 0 { | ||||||
| 			frm.PhotoPanorama.Value = photo.PhotoPanorama | 			frm.PhotoPanorama.Value = photo.PhotoPanorama | ||||||
| 			frm.PhotoPanorama.Matches = true |  | ||||||
| 		} else if photo.PhotoPanorama != frm.PhotoPanorama.Value { | 		} else if photo.PhotoPanorama != frm.PhotoPanorama.Value { | ||||||
| 			frm.PhotoPanorama.Matches = false | 			frm.PhotoPanorama.Mixed = true | ||||||
|  | 			frm.PhotoPanorama.Value = false | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if photo.CameraID != 0 && frm.CameraID.Value == 0 { | 		if i == 0 { | ||||||
| 			frm.CameraID.Value = photo.CameraID | 			frm.CameraID.Value = photo.CameraID | ||||||
| 			frm.CameraID.Matches = true |  | ||||||
| 		} else if photo.CameraID != frm.CameraID.Value { | 		} else if photo.CameraID != frm.CameraID.Value { | ||||||
| 			frm.CameraID.Matches = false | 			frm.CameraID.Mixed = true | ||||||
|  | 			frm.CameraID.Value = 1 | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if photo.LensID != 0 && frm.LensID.Value == 0 { | 		if i == 0 { | ||||||
| 			frm.LensID.Value = photo.LensID | 			frm.LensID.Value = photo.LensID | ||||||
| 			frm.LensID.Matches = true |  | ||||||
| 		} else if photo.LensID != frm.LensID.Value { | 		} else if photo.LensID != frm.LensID.Value { | ||||||
| 			frm.LensID.Matches = false | 			frm.LensID.Mixed = true | ||||||
|  | 			frm.LensID.Value = 1 | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if photo.Details != nil { | 		if i == 0 { | ||||||
| 			if photo.Details.Subject != "" && frm.DetailsSubject.Value == "" { | 			frm.DetailsKeywords.Value = photo.DetailsKeywords | ||||||
| 				frm.DetailsSubject.Value = photo.Details.Subject | 		} else if photo.DetailsKeywords != frm.DetailsKeywords.Value { | ||||||
| 				frm.DetailsSubject.Matches = true | 			frm.DetailsKeywords.Mixed = true | ||||||
| 			} else if photo.Details.Subject != frm.DetailsSubject.Value { | 			frm.DetailsKeywords.Value = "" | ||||||
| 				frm.DetailsSubject.Matches = false |  | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 			if photo.Details.Artist != "" && frm.DetailsArtist.Value == "" { | 		if i == 0 { | ||||||
| 				frm.DetailsArtist.Value = photo.Details.Artist | 			frm.DetailsSubject.Value = photo.DetailsSubject | ||||||
| 				frm.DetailsArtist.Matches = true | 		} else if photo.DetailsSubject != frm.DetailsSubject.Value { | ||||||
| 			} else if photo.Details.Artist != frm.DetailsArtist.Value { | 			frm.DetailsSubject.Mixed = true | ||||||
| 				frm.DetailsArtist.Matches = false | 			frm.DetailsSubject.Value = "" | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 			if photo.Details.Copyright != "" && frm.DetailsCopyright.Value == "" { | 		if i == 0 { | ||||||
| 				frm.DetailsCopyright.Value = photo.Details.Copyright | 			frm.DetailsArtist.Value = photo.DetailsArtist | ||||||
| 				frm.DetailsCopyright.Matches = true | 		} else if photo.DetailsArtist != frm.DetailsArtist.Value { | ||||||
| 			} else if photo.Details.Copyright != frm.DetailsCopyright.Value { | 			frm.DetailsArtist.Mixed = true | ||||||
| 				frm.DetailsCopyright.Matches = false | 			frm.DetailsArtist.Value = "" | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 			if photo.Details.License != "" && frm.DetailsLicense.Value == "" { | 		if i == 0 { | ||||||
| 				frm.DetailsLicense.Value = photo.Details.License | 			frm.DetailsCopyright.Value = photo.DetailsCopyright | ||||||
| 				frm.DetailsLicense.Matches = true | 		} else if photo.DetailsCopyright != frm.DetailsCopyright.Value { | ||||||
| 			} else if photo.Details.License != frm.DetailsLicense.Value { | 			frm.DetailsCopyright.Mixed = true | ||||||
| 				frm.DetailsLicense.Matches = false | 			frm.DetailsCopyright.Value = "" | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		if i == 0 { | ||||||
|  | 			frm.DetailsLicense.Value = photo.DetailsLicense | ||||||
|  | 		} else if photo.DetailsLicense != frm.DetailsLicense.Value { | ||||||
|  | 			frm.DetailsLicense.Mixed = true | ||||||
|  | 			frm.DetailsLicense.Value = "" | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | 	// Use defaults for the following values if they are empty: | ||||||
|  | 	if frm.PhotoCountry.Value == "" { | ||||||
|  | 		frm.PhotoCountry.Value = "zz" | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if frm.CameraID.Value < 1 { | ||||||
|  | 		frm.CameraID.Value = 1 | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if frm.LensID.Value < 1 { | ||||||
|  | 		frm.LensID.Value = 1 | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	return frm | 	return frm | ||||||
|   | |||||||
							
								
								
									
										61
									
								
								internal/form/batch/photos_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								internal/form/batch/photos_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,61 @@ | |||||||
|  | package batch | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"encoding/json" | ||||||
|  | 	"os" | ||||||
|  | 	"testing" | ||||||
|  |  | ||||||
|  | 	"github.com/stretchr/testify/assert" | ||||||
|  |  | ||||||
|  | 	"github.com/photoprism/photoprism/internal/entity/search" | ||||||
|  | 	"github.com/photoprism/photoprism/pkg/fs" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | func TestNewPhotosForm(t *testing.T) { | ||||||
|  | 	t.Run("Success", func(t *testing.T) { | ||||||
|  | 		var photos search.PhotoResults | ||||||
|  |  | ||||||
|  | 		dataFile := fs.Abs("./testdata/photos.json") | ||||||
|  | 		data, dataErr := os.ReadFile(dataFile) | ||||||
|  |  | ||||||
|  | 		if dataErr != nil { | ||||||
|  | 			t.Fatal(dataErr) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		jsonErr := json.Unmarshal(data, &photos) | ||||||
|  |  | ||||||
|  | 		if jsonErr != nil { | ||||||
|  | 			t.Fatal(jsonErr) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		frm := NewPhotosForm(photos) | ||||||
|  |  | ||||||
|  | 		// Photo metadata. | ||||||
|  | 		assert.Equal(t, "", frm.PhotoType.Value) | ||||||
|  | 		assert.Equal(t, true, frm.PhotoType.Mixed) | ||||||
|  | 		assert.Equal(t, "", frm.PhotoTitle.Value) | ||||||
|  | 		assert.Equal(t, true, frm.PhotoTitle.Mixed) | ||||||
|  | 		assert.Equal(t, "", frm.PhotoCaption.Value) | ||||||
|  | 		assert.Equal(t, true, frm.PhotoCaption.Mixed) | ||||||
|  | 		assert.Equal(t, false, frm.PhotoFavorite.Value) | ||||||
|  | 		assert.Equal(t, true, frm.PhotoFavorite.Mixed) | ||||||
|  | 		assert.Equal(t, false, frm.PhotoPrivate.Value) | ||||||
|  | 		assert.Equal(t, false, frm.PhotoPrivate.Mixed) | ||||||
|  | 		assert.Equal(t, uint(1000003), frm.CameraID.Value) | ||||||
|  | 		assert.Equal(t, false, frm.CameraID.Mixed) | ||||||
|  | 		assert.Equal(t, uint(1000000), frm.LensID.Value) | ||||||
|  | 		assert.Equal(t, false, frm.LensID.Mixed) | ||||||
|  |  | ||||||
|  | 		// Additional details. | ||||||
|  | 		assert.Equal(t, "", frm.DetailsKeywords.Value) | ||||||
|  | 		assert.Equal(t, true, frm.DetailsKeywords.Mixed) | ||||||
|  | 		assert.Equal(t, "", frm.DetailsSubject.Value) | ||||||
|  | 		assert.Equal(t, true, frm.DetailsSubject.Mixed) | ||||||
|  | 		assert.Equal(t, "", frm.DetailsArtist.Value) | ||||||
|  | 		assert.Equal(t, true, frm.DetailsArtist.Mixed) | ||||||
|  | 		assert.Equal(t, "", frm.DetailsCopyright.Value) | ||||||
|  | 		assert.Equal(t, true, frm.DetailsCopyright.Mixed) | ||||||
|  | 		assert.Equal(t, "", frm.DetailsLicense.Value) | ||||||
|  | 		assert.Equal(t, true, frm.DetailsLicense.Mixed) | ||||||
|  | 	}) | ||||||
|  | } | ||||||
							
								
								
									
										31
									
								
								internal/form/batch/selection.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								internal/form/batch/selection.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | |||||||
|  | package batch | ||||||
|  |  | ||||||
|  | import "strings" | ||||||
|  |  | ||||||
|  | // PhotosRequest represents items selected in the user interface. | ||||||
|  | type PhotosRequest struct { | ||||||
|  | 	Return bool        `json:"return,omitempty"` | ||||||
|  | 	Filter string      `json:"filter,omitempty"` | ||||||
|  | 	Photos []string    `json:"photos"` | ||||||
|  | 	Values *PhotosForm `json:"values,omitempty"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Empty checks if any specific items were selected. | ||||||
|  | func (f PhotosRequest) Empty() bool { | ||||||
|  | 	switch { | ||||||
|  | 	case len(f.Photos) > 0: | ||||||
|  | 		return false | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return true | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Get returns a string slice with the selected item UIDs. | ||||||
|  | func (f PhotosRequest) Get() []string { | ||||||
|  | 	return f.Photos | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // String returns a string containing all selected item UIDs. | ||||||
|  | func (f PhotosRequest) String() string { | ||||||
|  | 	return strings.Join(f.Get(), ", ") | ||||||
|  | } | ||||||
							
								
								
									
										442
									
								
								internal/form/batch/testdata/photos.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										442
									
								
								internal/form/batch/testdata/photos.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,442 @@ | |||||||
|  | [ | ||||||
|  |   { | ||||||
|  |     "ID": "1000003-1000011", | ||||||
|  |     "UID": "ps6sg6be2lvl0yh0", | ||||||
|  |     "Type": "video", | ||||||
|  |     "TypeSrc": "", | ||||||
|  |     "TakenAt": "1990-04-18T01:00:00Z", | ||||||
|  |     "TakenAtLocal": "1990-04-18T01:00:00Z", | ||||||
|  |     "TakenSrc": "meta", | ||||||
|  |     "TimeZone": "Local", | ||||||
|  |     "Path": "", | ||||||
|  |     "Name": "", | ||||||
|  |     "Title": "", | ||||||
|  |     "Caption": "", | ||||||
|  |     "Year": 1990, | ||||||
|  |     "Month": 4, | ||||||
|  |     "Day": 18, | ||||||
|  |     "Country": "za", | ||||||
|  |     "Stack": 0, | ||||||
|  |     "Favorite": false, | ||||||
|  |     "Private": false, | ||||||
|  |     "Iso": 400, | ||||||
|  |     "FocalLength": 84, | ||||||
|  |     "FNumber": 4.5, | ||||||
|  |     "Exposure": "", | ||||||
|  |     "Faces": 1, | ||||||
|  |     "Quality": 4, | ||||||
|  |     "Resolution": 45, | ||||||
|  |     "Duration": 7200000000000, | ||||||
|  |     "Color": 12, | ||||||
|  |     "Scan": false, | ||||||
|  |     "Panorama": false, | ||||||
|  |     "CameraID": 1000003, | ||||||
|  |     "CameraMake": "Canon", | ||||||
|  |     "CameraModel": "EOS 6D", | ||||||
|  |     "LensID": 1000000, | ||||||
|  |     "LensMake": "Apple", | ||||||
|  |     "LensModel": "F380", | ||||||
|  |     "Altitude": -100, | ||||||
|  |     "Lat": 48.519234, | ||||||
|  |     "Lng": 9.057997, | ||||||
|  |     "CellID": "", | ||||||
|  |     "PlaceID": "", | ||||||
|  |     "PlaceSrc": "", | ||||||
|  |     "PlaceLabel": "", | ||||||
|  |     "PlaceCity": "", | ||||||
|  |     "PlaceState": "", | ||||||
|  |     "PlaceCountry": "", | ||||||
|  |     "InstanceID": "", | ||||||
|  |     "FileUID": "fs6sg6bw15bnlqdw", | ||||||
|  |     "FileRoot": "/", | ||||||
|  |     "FileName": "1990/04/bridge2.jpg", | ||||||
|  |     "OriginalName": "", | ||||||
|  |     "Hash": "pcad9168fa6acc5c5c2965adf6ec465ca42fd818", | ||||||
|  |     "Width": 1200, | ||||||
|  |     "Height": 1600, | ||||||
|  |     "Portrait": true, | ||||||
|  |     "Merged": true, | ||||||
|  |     "CreatedAt": "0001-01-01T00:00:00Z", | ||||||
|  |     "UpdatedAt": "0001-01-01T00:00:00Z", | ||||||
|  |     "EditedAt": "0001-01-01T00:00:00Z", | ||||||
|  |     "CheckedAt": "0001-01-01T00:00:00Z", | ||||||
|  |     "DetailsKeywords": "bridge, nature", | ||||||
|  |     "DetailsSubject": "Bridge", | ||||||
|  |     "DetailsArtist": "Jens Mander", | ||||||
|  |     "DetailsCopyright": "Copyright 2020", | ||||||
|  |     "DetailsLicense": "n/a", | ||||||
|  |     "Files": [ | ||||||
|  |       { | ||||||
|  |         "UID": "fs6sg6bw15bnlqdw", | ||||||
|  |         "PhotoUID": "ps6sg6be2lvl0yh0", | ||||||
|  |         "Name": "1990/04/bridge2.jpg", | ||||||
|  |         "Root": "/", | ||||||
|  |         "Hash": "pcad9168fa6acc5c5c2965adf6ec465ca42fd818", | ||||||
|  |         "Size": 921858, | ||||||
|  |         "Primary": true, | ||||||
|  |         "Codec": "jpeg", | ||||||
|  |         "FileType": "jpg", | ||||||
|  |         "MediaType": "image", | ||||||
|  |         "Mime": "image/jpg", | ||||||
|  |         "Portrait": true, | ||||||
|  |         "Width": 1200, | ||||||
|  |         "Height": 1600, | ||||||
|  |         "Orientation": 6, | ||||||
|  |         "AspectRatio": 0.75, | ||||||
|  |         "CreatedAt": "0001-01-01T00:00:00Z", | ||||||
|  |         "UpdatedAt": "0001-01-01T00:00:00Z", | ||||||
|  |         "Markers": [ | ||||||
|  |           { | ||||||
|  |             "UID": "ms6sg6b1wowuy777", | ||||||
|  |             "FileUID": "fs6sg6bw15bnlqdw", | ||||||
|  |             "Type": "face", | ||||||
|  |             "Src": "image", | ||||||
|  |             "Name": "", | ||||||
|  |             "Review": false, | ||||||
|  |             "Invalid": false, | ||||||
|  |             "FaceID": "TOSCDXCS4VI3PGIUTCNIQCNI6HSFXQVZ", | ||||||
|  |             "FaceDist": 0.6, | ||||||
|  |             "SubjUID": "", | ||||||
|  |             "SubjSrc": "", | ||||||
|  |             "X": 0.404687, | ||||||
|  |             "Y": 0.249707, | ||||||
|  |             "W": 0.214062, | ||||||
|  |             "H": 0.321219, | ||||||
|  |             "Size": 200, | ||||||
|  |             "Score": 74, | ||||||
|  |             "Thumb": "pcad9168fa6acc5c5c2965ddf6ec465ca42fd818", | ||||||
|  |             "CreatedAt": "2025-05-04T11:49:42.529705909Z" | ||||||
|  |           } | ||||||
|  |         ] | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         "UID": "fs6sg6bwhhbnlqdn", | ||||||
|  |         "PhotoUID": "ps6sg6be2lvl0yh0", | ||||||
|  |         "Name": "London/bridge3.jpg", | ||||||
|  |         "Root": "/", | ||||||
|  |         "Hash": "pcad9168fa6acc5c5ba965adf6ec465ca42fd818", | ||||||
|  |         "Size": 921851, | ||||||
|  |         "Primary": false, | ||||||
|  |         "Codec": "jpeg", | ||||||
|  |         "FileType": "jpg", | ||||||
|  |         "MediaType": "image", | ||||||
|  |         "Mime": "image/jpg", | ||||||
|  |         "Portrait": true, | ||||||
|  |         "Width": 1200, | ||||||
|  |         "Height": 1600, | ||||||
|  |         "Orientation": 6, | ||||||
|  |         "AspectRatio": 0.75, | ||||||
|  |         "CreatedAt": "0001-01-01T00:00:00Z", | ||||||
|  |         "UpdatedAt": "0001-01-01T00:00:00Z", | ||||||
|  |         "Markers": [ | ||||||
|  |           { | ||||||
|  |             "UID": "ms6sg6b1wowu1000", | ||||||
|  |             "FileUID": "fs6sg6bwhhbnlqdn", | ||||||
|  |             "Type": "face", | ||||||
|  |             "Src": "image", | ||||||
|  |             "Name": "Actress A", | ||||||
|  |             "Review": false, | ||||||
|  |             "Invalid": false, | ||||||
|  |             "FaceID": "GMH5NISEEULNJL6RATITOA3TMZXMTMCI", | ||||||
|  |             "FaceDist": 0.4507357278575355, | ||||||
|  |             "SubjUID": "js6sg6b1h1njaaac", | ||||||
|  |             "SubjSrc": "", | ||||||
|  |             "X": 0.464844, | ||||||
|  |             "Y": 0.449531, | ||||||
|  |             "W": 0.434375, | ||||||
|  |             "H": 0.652582, | ||||||
|  |             "Size": 556, | ||||||
|  |             "Score": 155, | ||||||
|  |             "Thumb": "pcad9168fa6acc5c5c2965ddf6ec465ca42fd818-046045043065", | ||||||
|  |             "CreatedAt": "2025-05-04T11:49:42.442163583Z" | ||||||
|  |           } | ||||||
|  |         ] | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         "UID": "fs6sg6bwhhbnlqdy", | ||||||
|  |         "PhotoUID": "ps6sg6be2lvl0yh0", | ||||||
|  |         "Name": "1990/04/bridge2.mp4", | ||||||
|  |         "Root": "/", | ||||||
|  |         "Hash": "pcad9168fa6acc5c5ba965adf6ec465ca42fd819", | ||||||
|  |         "Size": 921851, | ||||||
|  |         "Primary": false, | ||||||
|  |         "Codec": "avc1", | ||||||
|  |         "FileType": "mp4", | ||||||
|  |         "MediaType": "video", | ||||||
|  |         "Mime": "image/mp4", | ||||||
|  |         "Portrait": true, | ||||||
|  |         "Video": true, | ||||||
|  |         "Duration": 17000000000, | ||||||
|  |         "Width": 1200, | ||||||
|  |         "Height": 1600, | ||||||
|  |         "Orientation": 6, | ||||||
|  |         "AspectRatio": 0.75, | ||||||
|  |         "CreatedAt": "0001-01-01T00:00:00Z", | ||||||
|  |         "UpdatedAt": "0001-01-01T00:00:00Z", | ||||||
|  |         "Markers": [] | ||||||
|  |       } | ||||||
|  |     ] | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     "ID": "1000001-1000001", | ||||||
|  |     "UID": "ps6sg6be2lvl0yh8", | ||||||
|  |     "Type": "raw", | ||||||
|  |     "TypeSrc": "", | ||||||
|  |     "TakenAt": "2006-01-01T02:00:00Z", | ||||||
|  |     "TakenAtLocal": "2006-01-01T02:00:00Z", | ||||||
|  |     "TakenSrc": "meta", | ||||||
|  |     "TimeZone": "Europe/Berlin", | ||||||
|  |     "Path": "", | ||||||
|  |     "Name": "", | ||||||
|  |     "Title": "", | ||||||
|  |     "Caption": "photo caption non-photographic", | ||||||
|  |     "Year": 2790, | ||||||
|  |     "Month": 2, | ||||||
|  |     "Day": 12, | ||||||
|  |     "Country": "de", | ||||||
|  |     "Stack": 0, | ||||||
|  |     "Favorite": true, | ||||||
|  |     "Private": false, | ||||||
|  |     "Iso": 305, | ||||||
|  |     "FocalLength": 28, | ||||||
|  |     "FNumber": 3.5, | ||||||
|  |     "Exposure": "", | ||||||
|  |     "Quality": 3, | ||||||
|  |     "Resolution": 2, | ||||||
|  |     "Color": 3, | ||||||
|  |     "Scan": false, | ||||||
|  |     "Panorama": false, | ||||||
|  |     "CameraID": 1000003, | ||||||
|  |     "CameraMake": "Canon", | ||||||
|  |     "CameraModel": "EOS 6D", | ||||||
|  |     "LensID": 1000000, | ||||||
|  |     "LensMake": "Apple", | ||||||
|  |     "LensModel": "F380", | ||||||
|  |     "Altitude": -10, | ||||||
|  |     "Lat": 48.519234, | ||||||
|  |     "Lng": 9.057997, | ||||||
|  |     "CellID": "", | ||||||
|  |     "PlaceID": "", | ||||||
|  |     "PlaceSrc": "", | ||||||
|  |     "PlaceLabel": "", | ||||||
|  |     "PlaceCity": "", | ||||||
|  |     "PlaceState": "", | ||||||
|  |     "PlaceCountry": "", | ||||||
|  |     "InstanceID": "", | ||||||
|  |     "FileUID": "fs6sg6bw45bn0001", | ||||||
|  |     "FileRoot": "/", | ||||||
|  |     "FileName": "2790/02/Photo01.dng", | ||||||
|  |     "OriginalName": "", | ||||||
|  |     "Hash": "3cad9168fa6acc5c5c2965ddf6ec465ca42fd818", | ||||||
|  |     "Width": 1200, | ||||||
|  |     "Height": 1600, | ||||||
|  |     "Portrait": true, | ||||||
|  |     "Merged": false, | ||||||
|  |     "CreatedAt": "0001-01-01T00:00:00Z", | ||||||
|  |     "UpdatedAt": "0001-01-01T00:00:00Z", | ||||||
|  |     "EditedAt": "0001-01-01T00:00:00Z", | ||||||
|  |     "CheckedAt": "0001-01-01T00:00:00Z", | ||||||
|  |     "DetailsKeywords": "screenshot, info", | ||||||
|  |     "DetailsSubject": "Non Photographic", | ||||||
|  |     "DetailsArtist": "Hans", | ||||||
|  |     "DetailsCopyright": "copy", | ||||||
|  |     "DetailsLicense": "MIT", | ||||||
|  |     "Files": [ | ||||||
|  |       { | ||||||
|  |         "UID": "fs6sg6bw45bn0001", | ||||||
|  |         "PhotoUID": "ps6sg6be2lvl0yh8", | ||||||
|  |         "Name": "2790/02/Photo01.dng", | ||||||
|  |         "Root": "/", | ||||||
|  |         "Hash": "3cad9168fa6acc5c5c2965ddf6ec465ca42fd818", | ||||||
|  |         "Size": 661858, | ||||||
|  |         "Primary": false, | ||||||
|  |         "Codec": "jpeg", | ||||||
|  |         "FileType": "raw", | ||||||
|  |         "MediaType": "raw", | ||||||
|  |         "Mime": "image/DNG", | ||||||
|  |         "Portrait": true, | ||||||
|  |         "Width": 1200, | ||||||
|  |         "Height": 1600, | ||||||
|  |         "Orientation": 6, | ||||||
|  |         "AspectRatio": 0.75, | ||||||
|  |         "CreatedAt": "0001-01-01T00:00:00Z", | ||||||
|  |         "UpdatedAt": "0001-01-01T00:00:00Z", | ||||||
|  |         "Markers": [] | ||||||
|  |       } | ||||||
|  |     ] | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     "ID": "1000000-1000000", | ||||||
|  |     "UID": "ps6sg6be2lvl0yh7", | ||||||
|  |     "Type": "image", | ||||||
|  |     "TypeSrc": "", | ||||||
|  |     "TakenAt": "2008-07-01T10:00:00Z", | ||||||
|  |     "TakenAtLocal": "2008-07-01T12:00:00Z", | ||||||
|  |     "TakenSrc": "meta", | ||||||
|  |     "TimeZone": "Europe/Berlin", | ||||||
|  |     "Path": "", | ||||||
|  |     "Name": "", | ||||||
|  |     "Title": "Lake / 2790", | ||||||
|  |     "Caption": "photo caption lake", | ||||||
|  |     "Year": 2790, | ||||||
|  |     "Month": 7, | ||||||
|  |     "Day": 4, | ||||||
|  |     "Country": "zz", | ||||||
|  |     "Stack": 0, | ||||||
|  |     "Favorite": false, | ||||||
|  |     "Private": false, | ||||||
|  |     "Iso": 200, | ||||||
|  |     "FocalLength": 50, | ||||||
|  |     "FNumber": 5, | ||||||
|  |     "Exposure": "1/80", | ||||||
|  |     "Faces": 3, | ||||||
|  |     "Quality": 3, | ||||||
|  |     "Resolution": 2, | ||||||
|  |     "Color": 9, | ||||||
|  |     "Scan": false, | ||||||
|  |     "Panorama": false, | ||||||
|  |     "CameraID": 1000003, | ||||||
|  |     "CameraSrc": "meta", | ||||||
|  |     "CameraMake": "Canon", | ||||||
|  |     "CameraModel": "EOS 6D", | ||||||
|  |     "LensID": 1000000, | ||||||
|  |     "LensMake": "Apple", | ||||||
|  |     "LensModel": "F380", | ||||||
|  |     "Lat": 0, | ||||||
|  |     "Lng": 0, | ||||||
|  |     "CellID": "", | ||||||
|  |     "PlaceID": "", | ||||||
|  |     "PlaceSrc": "", | ||||||
|  |     "PlaceLabel": "", | ||||||
|  |     "PlaceCity": "", | ||||||
|  |     "PlaceState": "", | ||||||
|  |     "PlaceCountry": "", | ||||||
|  |     "InstanceID": "", | ||||||
|  |     "FileUID": "fs6sg6bw45bnlqdw", | ||||||
|  |     "FileRoot": "/", | ||||||
|  |     "FileName": "2790/07/27900704_070228_D6D51B6C.jpg", | ||||||
|  |     "OriginalName": "Vacation/exampleFileNameOriginal.jpg", | ||||||
|  |     "Hash": "2cad9168fa6acc5c5c2965ddf6ec465ca42fd818", | ||||||
|  |     "Width": 3648, | ||||||
|  |     "Height": 2736, | ||||||
|  |     "Portrait": false, | ||||||
|  |     "Merged": false, | ||||||
|  |     "CreatedAt": "0001-01-01T00:00:00Z", | ||||||
|  |     "UpdatedAt": "0001-01-01T00:00:00Z", | ||||||
|  |     "EditedAt": "0001-01-01T00:00:00Z", | ||||||
|  |     "CheckedAt": "0001-01-01T00:00:00Z", | ||||||
|  |     "DetailsKeywords": "nature, frog", | ||||||
|  |     "DetailsSubject": "Lake", | ||||||
|  |     "DetailsArtist": "Hans", | ||||||
|  |     "DetailsCopyright": "copy", | ||||||
|  |     "DetailsLicense": "MIT", | ||||||
|  |     "Files": [ | ||||||
|  |       { | ||||||
|  |         "UID": "fs6sg6bw45bnlqdw", | ||||||
|  |         "PhotoUID": "ps6sg6be2lvl0yh7", | ||||||
|  |         "Name": "2790/07/27900704_070228_D6D51B6C.jpg", | ||||||
|  |         "Root": "/", | ||||||
|  |         "Hash": "2cad9168fa6acc5c5c2965ddf6ec465ca42fd818", | ||||||
|  |         "Size": 4278906, | ||||||
|  |         "Primary": true, | ||||||
|  |         "OriginalName": "Vacation/exampleFileNameOriginal.jpg", | ||||||
|  |         "Codec": "jpeg", | ||||||
|  |         "FileType": "jpg", | ||||||
|  |         "MediaType": "image", | ||||||
|  |         "Mime": "image/jpg", | ||||||
|  |         "Width": 3648, | ||||||
|  |         "Height": 2736, | ||||||
|  |         "Projection": "equirectangular", | ||||||
|  |         "AspectRatio": 1.33333, | ||||||
|  |         "CreatedAt": "0001-01-01T00:00:00Z", | ||||||
|  |         "UpdatedAt": "0001-01-01T00:00:00Z", | ||||||
|  |         "Markers": [ | ||||||
|  |           { | ||||||
|  |             "UID": "ms6sg6b14ahkyd24", | ||||||
|  |             "FileUID": "fs6sg6bw45bnlqdw", | ||||||
|  |             "Type": "face", | ||||||
|  |             "Src": "image", | ||||||
|  |             "Name": "", | ||||||
|  |             "Review": false, | ||||||
|  |             "Invalid": false, | ||||||
|  |             "FaceID": "VF7ANLDET2BKZNT4VQWJMMC6HBEFDOG6", | ||||||
|  |             "FaceDist": 0.3139983399779298, | ||||||
|  |             "SubjUID": "", | ||||||
|  |             "SubjSrc": "", | ||||||
|  |             "X": 0.1, | ||||||
|  |             "Y": 0.229688, | ||||||
|  |             "W": 0.246334, | ||||||
|  |             "H": 0.29707, | ||||||
|  |             "Size": 209, | ||||||
|  |             "Score": 55, | ||||||
|  |             "Thumb": "acad9168fa6acc5c5c2965ddf6ec465ca42fd818", | ||||||
|  |             "CreatedAt": "2025-05-04T11:49:42.485807442Z" | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             "UID": "ms6sg6b1wowu1005", | ||||||
|  |             "FileUID": "fs6sg6bw45bnlqdw", | ||||||
|  |             "Type": "face", | ||||||
|  |             "Src": "image", | ||||||
|  |             "Name": "Actor A", | ||||||
|  |             "Review": false, | ||||||
|  |             "Invalid": false, | ||||||
|  |             "FaceID": "PI6A2XGOTUXEFI7CBF4KCI5I2I3JEJHS", | ||||||
|  |             "FaceDist": 0.3139983399779298, | ||||||
|  |             "SubjUID": "js6sg6b1h1njaaad", | ||||||
|  |             "SubjSrc": "", | ||||||
|  |             "X": 0.5, | ||||||
|  |             "Y": 0.429688, | ||||||
|  |             "W": 0.746334, | ||||||
|  |             "H": 0.49707, | ||||||
|  |             "Size": 509, | ||||||
|  |             "Score": 100, | ||||||
|  |             "Thumb": "pcad9168fa6acc5c5c2965ddf6ec465ca42fd818", | ||||||
|  |             "CreatedAt": "2025-05-04T11:49:42.626934696Z" | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             "UID": "ms6sg6b1wowuy888", | ||||||
|  |             "FileUID": "fs6sg6bw45bnlqdw", | ||||||
|  |             "Type": "face", | ||||||
|  |             "Src": "image", | ||||||
|  |             "Name": "", | ||||||
|  |             "Review": false, | ||||||
|  |             "Invalid": false, | ||||||
|  |             "FaceID": "TOSCDXCS4VI3PGIUTCNIQCNI6HSFXQVZ", | ||||||
|  |             "FaceDist": 0.6, | ||||||
|  |             "SubjUID": "", | ||||||
|  |             "SubjSrc": "", | ||||||
|  |             "X": 0.528125, | ||||||
|  |             "Y": 0.240328, | ||||||
|  |             "W": 0.3625, | ||||||
|  |             "H": 0.543962, | ||||||
|  |             "Size": 200, | ||||||
|  |             "Score": 56, | ||||||
|  |             "Thumb": "pcad9168fa6acc5c5c2965ddf6ec465ca42fd818", | ||||||
|  |             "CreatedAt": "2025-05-04T11:49:42.427572061Z" | ||||||
|  |           }, | ||||||
|  |           { | ||||||
|  |             "UID": "ms6sg6b1wowu1001", | ||||||
|  |             "FileUID": "fs6sg6bw45bnlqdw", | ||||||
|  |             "Type": "face", | ||||||
|  |             "Src": "image", | ||||||
|  |             "Name": "Actress A", | ||||||
|  |             "Review": false, | ||||||
|  |             "Invalid": false, | ||||||
|  |             "FaceID": "GMH5NISEEULNJL6RATITOA3TMZXMTMCI", | ||||||
|  |             "FaceDist": 0.5099754448545762, | ||||||
|  |             "SubjUID": "js6sg6b1h1njaaac", | ||||||
|  |             "SubjSrc": "", | ||||||
|  |             "X": 0.547656, | ||||||
|  |             "Y": 0.330986, | ||||||
|  |             "W": 0.402344, | ||||||
|  |             "H": 0.60446, | ||||||
|  |             "Size": 515, | ||||||
|  |             "Score": 102, | ||||||
|  |             "Thumb": "pcad9168fa6acc5c5c2965ddf6ec465ca42fd818-05403304060446", | ||||||
|  |             "CreatedAt": "2025-05-04T11:49:42.457213555Z" | ||||||
|  |           } | ||||||
|  |         ] | ||||||
|  |       } | ||||||
|  |     ] | ||||||
|  |   } | ||||||
|  | ] | ||||||
| @@ -7,48 +7,48 @@ import ( | |||||||
| // String represents batch edit form value. | // String represents batch edit form value. | ||||||
| type String struct { | type String struct { | ||||||
| 	Value  string `json:"value"` | 	Value  string `json:"value"` | ||||||
| 	Matches bool   `json:"matches"` | 	Mixed  bool   `json:"mixed"` | ||||||
| 	Action Action `json:"action"` | 	Action Action `json:"action"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // Bool represents batch edit form value. | // Bool represents batch edit form value. | ||||||
| type Bool struct { | type Bool struct { | ||||||
| 	Value  bool   `json:"value"` | 	Value  bool   `json:"value"` | ||||||
| 	Matches bool   `json:"matches"` | 	Mixed  bool   `json:"mixed"` | ||||||
| 	Action Action `json:"action"` | 	Action Action `json:"action"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // Time represents batch edit form value. | // Time represents batch edit form value. | ||||||
| type Time struct { | type Time struct { | ||||||
| 	Value  time.Time `json:"value"` | 	Value  time.Time `json:"value"` | ||||||
| 	Matches bool      `json:"matches"` | 	Mixed  bool      `json:"mixed"` | ||||||
| 	Action Action    `json:"action"` | 	Action Action    `json:"action"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // Int represents batch edit form value. | // Int represents batch edit form value. | ||||||
| type Int struct { | type Int struct { | ||||||
| 	Value  int    `json:"value"` | 	Value  int    `json:"value"` | ||||||
| 	Matches bool   `json:"matches"` | 	Mixed  bool   `json:"mixed"` | ||||||
| 	Action Action `json:"action"` | 	Action Action `json:"action"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // UInt represents batch edit form value. | // UInt represents batch edit form value. | ||||||
| type UInt struct { | type UInt struct { | ||||||
| 	Value  uint   `json:"value"` | 	Value  uint   `json:"value"` | ||||||
| 	Matches bool   `json:"matches"` | 	Mixed  bool   `json:"mixed"` | ||||||
| 	Action Action `json:"action"` | 	Action Action `json:"action"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // Float32 represents batch edit form value. | // Float32 represents batch edit form value. | ||||||
| type Float32 struct { | type Float32 struct { | ||||||
| 	Value  float32 `json:"value"` | 	Value  float32 `json:"value"` | ||||||
| 	Matches bool    `json:"matches"` | 	Mixed  bool    `json:"mixed"` | ||||||
| 	Action Action  `json:"action"` | 	Action Action  `json:"action"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // Float64 represents batch edit form value. | // Float64 represents batch edit form value. | ||||||
| type Float64 struct { | type Float64 struct { | ||||||
| 	Value  float64 `json:"value"` | 	Value  float64 `json:"value"` | ||||||
| 	Matches bool    `json:"matches"` | 	Mixed  bool    `json:"mixed"` | ||||||
| 	Action Action  `json:"action"` | 	Action Action  `json:"action"` | ||||||
| } | } | ||||||
|   | |||||||
| @@ -99,6 +99,7 @@ type SearchPhotos struct { | |||||||
| 	Offset      int       `form:"offset" serialize:"-"`                                                                                                                      // Result FILE offset | 	Offset      int       `form:"offset" serialize:"-"`                                                                                                                      // Result FILE offset | ||||||
| 	Order       string    `form:"order" serialize:"-"`                                                                                                                       // Sort order | 	Order       string    `form:"order" serialize:"-"`                                                                                                                       // Sort order | ||||||
| 	Merged      bool      `form:"merged" serialize:"-"`                                                                                                                      // Merge FILES in response | 	Merged      bool      `form:"merged" serialize:"-"`                                                                                                                      // Merge FILES in response | ||||||
|  | 	Details     bool      `form:"-" serialize:"-"`                                                                                                                           // Include additional information from details table | ||||||
| } | } | ||||||
|  |  | ||||||
| func (f *SearchPhotos) GetQuery() string { | func (f *SearchPhotos) GetQuery() string { | ||||||
|   | |||||||
| @@ -5,6 +5,7 @@ import "strings" | |||||||
| // Selection represents items selected in the user interface. | // Selection represents items selected in the user interface. | ||||||
| type Selection struct { | type Selection struct { | ||||||
| 	All      bool     `json:"all"` | 	All      bool     `json:"all"` | ||||||
|  | 	Filter   string   `json:"filter"` | ||||||
| 	Files    []string `json:"files"` | 	Files    []string `json:"files"` | ||||||
| 	Photos   []string `json:"photos"` | 	Photos   []string `json:"photos"` | ||||||
| 	Albums   []string `json:"albums"` | 	Albums   []string `json:"albums"` | ||||||
|   | |||||||
| @@ -182,14 +182,14 @@ func registerRoutes(router *gin.Engine, conf *config.Config) { | |||||||
| 	api.UpdateFace(APIv1) | 	api.UpdateFace(APIv1) | ||||||
|  |  | ||||||
| 	// Batch Operations. | 	// Batch Operations. | ||||||
| 	api.BatchPhotos(APIv1) | 	api.BatchAlbumsDelete(APIv1) | ||||||
|  | 	api.BatchLabelsDelete(APIv1) | ||||||
|  | 	api.BatchPhotosEdit(APIv1) | ||||||
| 	api.BatchPhotosApprove(APIv1) | 	api.BatchPhotosApprove(APIv1) | ||||||
| 	api.BatchPhotosArchive(APIv1) | 	api.BatchPhotosArchive(APIv1) | ||||||
| 	api.BatchPhotosRestore(APIv1) | 	api.BatchPhotosRestore(APIv1) | ||||||
| 	api.BatchPhotosPrivate(APIv1) | 	api.BatchPhotosPrivate(APIv1) | ||||||
| 	api.BatchPhotosDelete(APIv1) | 	api.BatchPhotosDelete(APIv1) | ||||||
| 	api.BatchAlbumsDelete(APIv1) |  | ||||||
| 	api.BatchLabelsDelete(APIv1) |  | ||||||
|  |  | ||||||
| 	// Technical Endpoints. | 	// Technical Endpoints. | ||||||
| 	api.GetSvg(APIv1) | 	api.GetSvg(APIv1) | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Michael Mayer
					Michael Mayer