diff --git a/frontend/src/component/components.js b/frontend/src/component/components.js index 81da1edd6..60a530b9a 100644 --- a/frontend/src/component/components.js +++ b/frontend/src/component/components.js @@ -2,11 +2,12 @@ import PNotify from "./p-notify.vue"; import PNavigation from "./p-navigation.vue"; import PLoadingBar from "./p-loading-bar.vue"; import PPhotoSearch from "./p-photo-search.vue"; -import PPhotoClipboard from "./p-photo-clipboard.vue"; import PPhotoDetails from "./p-photo-details.vue"; import PPhotoTiles from "./p-photo-tiles.vue"; import PPhotoMosaic from "./p-photo-mosaic.vue"; import PPhotoList from "./p-photo-list.vue"; +import PPhotoClipboard from "./p-photo-clipboard.vue"; +import PLabelClipboard from "./p-label-clipboard.vue"; import PAlbumClipboard from "./p-album-clipboard.vue"; import PAlbumToolbar from "./p-album-toolbar.vue"; import PPhotoViewer from "./p-photo-viewer.vue"; @@ -25,6 +26,7 @@ components.install = (Vue) => { Vue.component("p-photo-list", PPhotoList); Vue.component("p-photo-search", PPhotoSearch); Vue.component("p-photo-clipboard", PPhotoClipboard); + Vue.component("p-label-clipboard", PLabelClipboard); Vue.component("p-album-clipboard", PAlbumClipboard); Vue.component("p-album-toolbar", PAlbumToolbar); Vue.component("p-scroll-top", PScrollTop); diff --git a/frontend/src/component/p-label-clipboard.vue b/frontend/src/component/p-label-clipboard.vue new file mode 100644 index 000000000..acecfe867 --- /dev/null +++ b/frontend/src/component/p-label-clipboard.vue @@ -0,0 +1,145 @@ + + + + + + menu + {{ selection.length }} + + + + + folder + + + delete + + + + clear + + + + + + + + diff --git a/frontend/src/css/app.css b/frontend/src/css/app.css index a918d98e3..27738bd6a 100644 --- a/frontend/src/css/app.css +++ b/frontend/src/css/app.css @@ -74,7 +74,6 @@ main { #photoprism main .p-inline-edit a, #photoprism main .p-inline-edit a span { - color: #333333; cursor: text; } diff --git a/frontend/src/css/photos.css b/frontend/src/css/photos.css index 45149f2ed..9148cc171 100644 --- a/frontend/src/css/photos.css +++ b/frontend/src/css/photos.css @@ -20,8 +20,12 @@ #photoprism .p-photo-tiles .p-photo-select, #photoprism .p-photo-details .p-photo-select, -#photoprism .p-photo-mosaic .p-photo-select, -#photoprism .p-albums-details .p-album-select { +#photoprism .p-photo-mosaic .p-photo-select { + right: 4px; bottom: 4px; +} + +#photoprism .p-albums-details .p-album-select, +#photoprism .p-labels-details .p-label-select { right: 4px; bottom: 4px; } diff --git a/frontend/src/dialog/dialogs.js b/frontend/src/dialog/dialogs.js index dcbc7cfb0..b36e8d6e5 100644 --- a/frontend/src/dialog/dialogs.js +++ b/frontend/src/dialog/dialogs.js @@ -3,6 +3,7 @@ import PPhotoAlbumDialog from "./p-photo-album-dialog.vue"; import PPhotoEditDialog from "./p-photo-edit-dialog.vue"; import PPhotoShareDialog from "./p-photo-share-dialog.vue"; import PAlbumDeleteDialog from "./p-album-delete-dialog.vue"; +import PLabelDeleteDialog from "./p-label-delete-dialog.vue"; import PUploadDialog from "./p-upload-dialog.vue"; const dialogs = {}; @@ -13,6 +14,7 @@ dialogs.install = (Vue) => { Vue.component("p-photo-edit-dialog", PPhotoEditDialog); Vue.component("p-photo-share-dialog", PPhotoShareDialog); Vue.component("p-album-delete-dialog", PAlbumDeleteDialog); + Vue.component("p-label-delete-dialog", PLabelDeleteDialog); Vue.component("p-upload-dialog", PUploadDialog); }; diff --git a/frontend/src/dialog/p-label-delete-dialog.vue b/frontend/src/dialog/p-label-delete-dialog.vue new file mode 100644 index 000000000..8297ad1ff --- /dev/null +++ b/frontend/src/dialog/p-label-delete-dialog.vue @@ -0,0 +1,43 @@ + + + + + + + delete_outline + + + Are you sure you want to delete these labels? + + + + Cancel + + Delete + + + + + + + + diff --git a/frontend/src/pages/albums.vue b/frontend/src/pages/albums.vue index ccc196e3a..89d6a58ff 100644 --- a/frontend/src/pages/albums.vue +++ b/frontend/src/pages/albums.vue @@ -50,7 +50,7 @@ - + check_circle @@ -328,6 +329,13 @@ this.selection.push(uuid) } }, + removeSelection(uuid) { + const pos = this.selection.indexOf(uuid); + + if (pos !== -1) { + this.selection.splice(pos, 1); + } + }, onUpdate(ev, data) { if (!this.listen) return; @@ -356,9 +364,12 @@ for (let i = 0; i < data.entities.length; i++) { const uuid = data.entities[i]; const index = this.results.findIndex((m) => m.AlbumUUID === uuid); + if (index >= 0) { this.results.splice(index, 1); } + + this.removeSelection(uuid) } break; diff --git a/frontend/src/pages/labels.vue b/frontend/src/pages/labels.vue index be54da2e3..dbbf9f274 100644 --- a/frontend/src/pages/labels.vue +++ b/frontend/src/pages/labels.vue @@ -36,10 +36,12 @@ + + - + @@ -51,7 +53,7 @@ - + - + + + check_circle + + radio_button_off + @@ -120,6 +134,7 @@ diff --git a/internal/api/album.go b/internal/api/album.go index f158be2dc..81f4f131a 100644 --- a/internal/api/album.go +++ b/internal/api/album.go @@ -244,37 +244,35 @@ func AddPhotosToAlbum(router *gin.RouterGroup, conf *config.Config) { return } - var f form.PhotoUUIDs + var f form.Selection if err := c.BindJSON(&f); err != nil { c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())}) return } - if len(f.Photos) == 0 { - log.Error("no photos selected") - c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst("no photos selected")}) - return - } - + uuid := c.Param("uuid") q := query.New(conf.OriginalsPath(), conf.Db()) - a, err := q.FindAlbumByUUID(c.Param("uuid")) + a, err := q.FindAlbumByUUID(uuid) if err != nil { c.AbortWithStatusJSON(http.StatusNotFound, ErrAlbumNotFound) return } + photos, err := q.PhotoSelection(f) + + if err != nil { + log.Errorf("album: %s", err) + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())}) + return + } + db := conf.Db() var added []*entity.PhotoAlbum - var failed []string - for _, photoUUID := range f.Photos { - if p, err := q.FindPhotoByUUID(photoUUID); err != nil { - failed = append(failed, photoUUID) - } else { - added = append(added, entity.NewPhotoAlbum(p.PhotoUUID, a.AlbumUUID).FirstOrCreate(db)) - } + for _, p := range photos { + added = append(added, entity.NewPhotoAlbum(p.PhotoUUID, a.AlbumUUID).FirstOrCreate(db)) } if len(added) == 1 { @@ -283,7 +281,7 @@ func AddPhotosToAlbum(router *gin.RouterGroup, conf *config.Config) { event.Success(fmt.Sprintf("%d photos added to %s", len(added), a.AlbumName)) } - c.JSON(http.StatusOK, gin.H{"message": "photos added to album", "album": a, "added": added, "failed": failed}) + c.JSON(http.StatusOK, gin.H{"message": "photos added to album", "album": a, "added": added}) }) } @@ -295,7 +293,7 @@ func RemovePhotosFromAlbum(router *gin.RouterGroup, conf *config.Config) { return } - var f form.PhotoUUIDs + var f form.Selection if err := c.BindJSON(&f); err != nil { c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())}) diff --git a/internal/api/label.go b/internal/api/label.go index ed9ec4226..5d69ccbe2 100644 --- a/internal/api/label.go +++ b/internal/api/label.go @@ -97,9 +97,10 @@ func LikeLabel(router *gin.RouterGroup, conf *config.Config) { return } + id := c.Param("uuid") q := query.New(conf.OriginalsPath(), conf.Db()) - label, err := q.FindLabelByUUID(c.Param("uuid")) + label, err := q.FindLabelByUUID(id) if err != nil { c.AbortWithStatusJSON(404, gin.H{"error": txt.UcFirst(err.Error())}) @@ -115,6 +116,8 @@ func LikeLabel(router *gin.RouterGroup, conf *config.Config) { }) } + PublishLabelEvent(EntityUpdated, id, c, q) + c.JSON(http.StatusOK, http.Response{}) }) } @@ -130,9 +133,10 @@ func DislikeLabel(router *gin.RouterGroup, conf *config.Config) { return } + id := c.Param("uuid") q := query.New(conf.OriginalsPath(), conf.Db()) - label, err := q.FindLabelByUUID(c.Param("uuid")) + label, err := q.FindLabelByUUID(id) if err != nil { c.AbortWithStatusJSON(404, gin.H{"error": txt.UcFirst(err.Error())}) @@ -148,6 +152,8 @@ func DislikeLabel(router *gin.RouterGroup, conf *config.Config) { }) } + PublishLabelEvent(EntityUpdated, id, c, q) + c.JSON(http.StatusOK, http.Response{}) }) } diff --git a/internal/api/batch.go b/internal/api/selection.go similarity index 83% rename from internal/api/batch.go rename to internal/api/selection.go index 06123cd4e..82bf34863 100644 --- a/internal/api/batch.go +++ b/internal/api/selection.go @@ -25,7 +25,7 @@ func BatchPhotosArchive(router *gin.RouterGroup, conf *config.Config) { start := time.Now() - var f form.PhotoUUIDs + var f form.Selection if err := c.BindJSON(&f); err != nil { c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())}) @@ -66,7 +66,7 @@ func BatchPhotosRestore(router *gin.RouterGroup, conf *config.Config) { start := time.Now() - var f form.PhotoUUIDs + var f form.Selection if err := c.BindJSON(&f); err != nil { c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())}) @@ -106,7 +106,7 @@ func BatchAlbumsDelete(router *gin.RouterGroup, conf *config.Config) { return } - var f form.AlbumUUIDs + var f form.Selection if err := c.BindJSON(&f); err != nil { c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())}) @@ -146,7 +146,7 @@ func BatchPhotosPrivate(router *gin.RouterGroup, conf *config.Config) { start := time.Now() - var f form.PhotoUUIDs + var f form.Selection if err := c.BindJSON(&f); err != nil { c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())}) @@ -181,7 +181,7 @@ func BatchPhotosStory(router *gin.RouterGroup, conf *config.Config) { start := time.Now() - var f form.PhotoUUIDs + var f form.Selection if err := c.BindJSON(&f); err != nil { c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())}) @@ -207,3 +207,40 @@ func BatchPhotosStory(router *gin.RouterGroup, conf *config.Config) { c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("photos marked as story in %s", elapsed)}) }) } + +// POST /api/v1/batch/labels/delete +func BatchLabelsDelete(router *gin.RouterGroup, conf *config.Config) { + router.POST("/batch/labels/delete", func(c *gin.Context) { + if Unauthorized(c, conf) { + c.AbortWithStatusJSON(http.StatusUnauthorized, ErrUnauthorized) + return + } + + var f form.Selection + + if err := c.BindJSON(&f); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst(err.Error())}) + return + } + + if len(f.Labels) == 0 { + log.Error("no labels selected") + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": txt.UcFirst("no labels selected")}) + return + } + + log.Infof("deleting labels: %#v", f.Labels) + + db := conf.Db() + + db.Where("label_uuid IN (?)", f.Labels).Delete(&entity.Label{}) + + event.Publish("config.updated", event.Data(conf.ClientConfig())) + + event.Publish("labels.deleted", event.Data{ + "entities": f.Labels, + }) + + c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("labels deleted")}) + }) +} diff --git a/internal/api/zip.go b/internal/api/zip.go index 99683c66c..d71392356 100644 --- a/internal/api/zip.go +++ b/internal/api/zip.go @@ -28,7 +28,7 @@ func CreateZip(router *gin.RouterGroup, conf *config.Config) { return } - var f form.PhotoUUIDs + var f form.Selection start := time.Now() if err := c.BindJSON(&f); err != nil { diff --git a/internal/form/album_uuids.go b/internal/form/album_uuids.go deleted file mode 100644 index 994a0d627..000000000 --- a/internal/form/album_uuids.go +++ /dev/null @@ -1,5 +0,0 @@ -package form - -type AlbumUUIDs struct { - Albums []string `json:"albums"` -} diff --git a/internal/form/photo_uuids.go b/internal/form/photo_uuids.go deleted file mode 100644 index e42419efb..000000000 --- a/internal/form/photo_uuids.go +++ /dev/null @@ -1,5 +0,0 @@ -package form - -type PhotoUUIDs struct { - Photos []string `json:"photos"` -} diff --git a/internal/form/selection.go b/internal/form/selection.go new file mode 100644 index 000000000..61942c843 --- /dev/null +++ b/internal/form/selection.go @@ -0,0 +1,15 @@ +package form + +type Selection struct { + Photos []string `json:"photos"` + Albums []string `json:"albums"` + Labels []string `json:"labels"` +} + +func (f Selection) Empty() bool { + if len(f.Photos) > 0 || len(f.Albums) > 0 || len(f.Labels) > 0 { + return false + } + + return true +} diff --git a/internal/maps/osm/title.go b/internal/maps/osm/name.go similarity index 93% rename from internal/maps/osm/title.go rename to internal/maps/osm/name.go index 80be24e69..bd54709ca 100644 --- a/internal/maps/osm/title.go +++ b/internal/maps/osm/name.go @@ -33,5 +33,7 @@ func (l Location) Name() (result string) { result = result[:i] } + result = strings.SplitN(result, "/", 2)[0] + return txt.Title(strings.TrimSpace(result)) } diff --git a/internal/maps/osm/title_test.go b/internal/maps/osm/name_test.go similarity index 100% rename from internal/maps/osm/title_test.go rename to internal/maps/osm/name_test.go diff --git a/internal/maps/places/location.go b/internal/maps/places/location.go index a40fd96fb..d8aef7aba 100644 --- a/internal/maps/places/location.go +++ b/internal/maps/places/location.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" gc "github.com/patrickmn/go-cache" "github.com/photoprism/photoprism/pkg/s2" @@ -89,7 +90,7 @@ func (l Location) CellID() (result string) { } func (l Location) Name() (result string) { - return l.LocName + return strings.SplitN(l.LocName, "/", 2)[0] } func (l Location) Category() (result string) { diff --git a/internal/query/selection.go b/internal/query/selection.go new file mode 100644 index 000000000..9bff05adb --- /dev/null +++ b/internal/query/selection.go @@ -0,0 +1,32 @@ +package query + +import ( + "errors" + + "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/internal/form" +) + +// PhotoSelection returns all selected photos. +func (s *Repo) PhotoSelection(f form.Selection) (results []entity.Photo, err error) { + if f.Empty() { + return results, errors.New("no photos selected") + } + + q := s.db.NewScope(nil).DB() + + q = q.Table("photos"). + Select("photos.*"). + Joins("LEFT JOIN photos_labels ON photos_labels.photo_id = photos.id"). + Joins("LEFT JOIN labels ON photos_labels.label_id = labels.id AND labels.deleted_at IS NULL"). + Where("photos.deleted_at IS NULL"). + Group("photos.id") + + q = q.Where("photos.photo_uuid IN (?) OR labels.label_uuid IN (?)", f.Photos, f.Labels) + + if result := q.Scan(&results); result.Error != nil { + return results, result.Error + } + + return results, nil +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 0f252f6e1..21bbcffb0 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -55,6 +55,7 @@ func registerRoutes(router *gin.Engine, conf *config.Config) { api.BatchPhotosPrivate(v1, conf) api.BatchPhotosStory(v1, conf) api.BatchAlbumsDelete(v1, conf) + api.BatchLabelsDelete(v1, conf) api.GetAlbum(v1, conf) api.CreateAlbum(v1, conf)