UX: Add Delete All button to archive page toolbar #272

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2023-07-23 17:57:48 +02:00
parent 90eac1966b
commit ad3da85ecb
9 changed files with 119 additions and 27 deletions

View file

@ -163,6 +163,10 @@ import Photo from "model/photo";
export default {
name: 'PPhotoClipboard',
props: {
context: {
type: String,
default: 'photos',
},
selection: {
type: Array,
default: () => [],
@ -175,10 +179,6 @@ export default {
type: Object,
default: () => {},
},
context: {
type: String,
default: '',
},
},
data() {
const features = this.$config.settings().features;

View file

@ -32,7 +32,11 @@
<v-icon>view_column</v-icon>
</v-btn>
<v-btn v-if="!$config.values.readonly && $config.feature('upload')" icon class="hidden-sm-and-down action-upload"
<v-btn v-if="canDelete && context === 'archive'" icon class="hidden-sm-and-down action-delete"
:title="$gettext('Delete')" @click.stop="deletePhotos()">
<v-icon>delete</v-icon>
</v-btn>
<v-btn v-else-if="canUpload" icon class="hidden-sm-and-down action-upload"
:title="$gettext('Upload')" @click.stop="showUpload()">
<v-icon>cloud_upload</v-icon>
</v-btn>
@ -166,15 +170,23 @@
</v-layout>
</v-card-text>
</v-card>
<p-photo-delete-dialog :show="dialog.delete" @cancel="dialog.delete = false"
@confirm="batchDelete"></p-photo-delete-dialog>
</v-form>
</template>
<script>
import Event from "pubsub-js";
import * as options from "options/options";
import Api from "common/api";
import Notify from "common/notify";
export default {
name: 'PPhotoToolbar',
props: {
context: {
type: String,
default: 'photos',
},
filter: {
type: Object,
default: () => {},
@ -197,10 +209,15 @@ export default {
},
},
data() {
const features = this.$config.settings().features;
const readonly = this.$config.get("readonly");
return {
experimental: this.$config.get("experimental"),
isFullScreen: !!document.fullscreenElement,
config: this.$config.values,
readonly: readonly,
canUpload: !readonly && this.$config.allow("files", "upload") && features.upload,
canDelete: !readonly && this.$config.allow("photos", "delete") && features.delete,
searchExpanded: false,
all: {
countries: [{ID: "", Name: this.$gettext("All Countries")}],
@ -229,6 +246,9 @@ export default {
{value: 'similar', text: this.$gettext('Visual Similarity')},
],
},
dialog: {
delete: false,
},
};
},
computed: {
@ -262,7 +282,27 @@ export default {
},
showUpload() {
Event.publish("dialog.upload");
}
},
deletePhotos() {
if (!this.canDelete) {
return;
}
this.dialog.delete = true;
},
batchDelete() {
if (!this.canDelete) {
return;
}
this.dialog.delete = false;
Api.post("batch/photos/delete", {"all": true}).then(() => this.onDeleted());
},
onDeleted() {
Notify.success(this.$gettext("Permanently deleted"));
this.$clipboard.clear();
},
},
};
</script>

View file

@ -3,7 +3,7 @@
:infinite-scroll-disabled="scrollDisabled" :infinite-scroll-distance="scrollDistance"
:infinite-scroll-listen-for-event="'scrollRefresh'">
<p-photo-toolbar :filter="filter" :settings="settings" :refresh="refresh"
<p-photo-toolbar :context="context" :filter="filter" :settings="settings" :refresh="refresh"
:update-filter="updateFilter" :update-query="updateQuery"></p-photo-toolbar>
<v-container v-if="loading" fluid class="pa-4">
@ -12,7 +12,7 @@
<v-container v-else fluid class="pa-0">
<p-scroll-top></p-scroll-top>
<p-photo-clipboard :refresh="refresh" :selection="selection" :context="context"></p-photo-clipboard>
<p-photo-clipboard :context="context" :refresh="refresh" :selection="selection"></p-photo-clipboard>
<p-photo-mosaic v-if="settings.view === 'mosaic'"
:context="context"

View file

@ -361,19 +361,37 @@ func BatchPhotosDelete(router *gin.RouterGroup) {
return
}
if len(f.Photos) == 0 {
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
deleteStart := time.Now()
var photos entity.Photos
var err error
// Abort if user wants to delete all but does not have sufficient privileges.
if f.All && !acl.Resources.AllowAll(acl.ResourcePhotos, s.User().AclRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) {
AbortForbidden(c)
return
}
log.Infof("photos: deleting %s", clean.Log(f.String()))
// Fetch selection from index and record time.
deleteStart := time.Now()
photos, err := query.SelectedPhotos(f)
// Get selection or all archived photos if f.All is true.
if len(f.Photos) == 0 && !f.All {
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
return
} else if f.All {
log.Infof("archive: deleting all archived photos", clean.Log(f.String()))
photos, err = query.ArchivedPhotos(1000000, 0)
} else {
photos, err = query.SelectedPhotos(f)
}
// Abort if the query failed or no photos were found.
if err != nil {
AbortEntityNotFound(c)
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
}
@ -398,8 +416,8 @@ func BatchPhotosDelete(router *gin.RouterGroup) {
}
}
if numFiles > 0 {
log.Infof("delete: removed %s [%s]", english.Plural(numFiles, "file", "files"), time.Since(deleteStart))
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?

View file

@ -2,7 +2,9 @@ package form
import "strings"
// Selection represents items selected in the user interface.
type Selection struct {
All bool `json:"all"`
Files []string `json:"files"`
Photos []string `json:"photos"`
Albums []string `json:"albums"`
@ -11,6 +13,7 @@ type Selection struct {
Subjects []string `json:"subjects"`
}
// Empty checks if any specific items were selected.
func (f Selection) Empty() bool {
switch {
case len(f.Files) > 0:
@ -30,7 +33,8 @@ func (f Selection) Empty() bool {
return true
}
func (f Selection) All() []string {
// Get returns a string slice with the selected item UIDs.
func (f Selection) Get() []string {
var all []string
copy(all, f.Files)
@ -44,6 +48,7 @@ func (f Selection) All() []string {
return all
}
// String returns a string containing all selected item UIDs.
func (f Selection) String() string {
return strings.Join(f.All(), ", ")
return strings.Join(f.Get(), ", ")
}

View file

@ -38,10 +38,10 @@ func TestSelection_Empty(t *testing.T) {
})
}
func TestSelection_All(t *testing.T) {
func TestSelection_Get(t *testing.T) {
t.Run("success", func(t *testing.T) {
sel := Selection{Photos: []string{"p123", "p456"}, Albums: []string{"a123"}, Labels: []string{"l123", "l456", "l789"}, Files: []string{"f567", "f111"}, Places: []string{"p568"}, Subjects: []string{"jqzkpo13j8ngpgv4"}}
assert.Equal(t, []string{"p123", "p456", "a123", "l123", "l456", "l789", "p568", "jqzkpo13j8ngpgv4"}, sel.All())
assert.Equal(t, []string{"p123", "p456", "a123", "l123", "l456", "l789", "p568", "jqzkpo13j8ngpgv4"}, sel.Get())
})
}

View file

@ -215,7 +215,7 @@ func (w *Purge) Start(opt PurgeOptions) (purgedFiles map[string]bool, purgedPhot
limit = 10000
offset = 0
for {
photos, err := query.PhotosMissing(limit, offset)
photos, err := query.MissingPhotos(limit, offset)
if err != nil {
return purgedFiles, purgedPhotos, updates(), err

View file

@ -72,13 +72,26 @@ func PhotoPreloadByUID(photoUID string) (photo entity.Photo, err error) {
return photo, nil
}
// PhotosMissing returns photo entities without existing files.
func PhotosMissing(limit int, offset int) (entities entity.Photos, err error) {
// MissingPhotos returns photo entities without existing files.
func MissingPhotos(limit int, offset int) (entities entity.Photos, err error) {
err = Db().
Select("photos.*").
Where("id NOT IN (SELECT photo_id FROM files WHERE file_missing = 0 AND file_root = '/' AND deleted_at IS NULL)").
Where("photos.photo_type <> ?", entity.MediaText).
Group("photos.id").
Order("photos.id").
Limit(limit).Offset(offset).Find(&entities).Error
return entities, err
}
// ArchivedPhotos finds and returns archived photos.
func ArchivedPhotos(limit int, offset int) (entities entity.Photos, err error) {
err = UnscopedDb().
Select("photos.*").
Where("photos.photo_quality > -1").
Where("photos.deleted_at IS NOT NULL").
Where("photos.photo_type <> ?", entity.MediaText).
Order("photos.id").
Limit(limit).Offset(offset).Find(&entities).Error
return entities, err

View file

@ -58,7 +58,7 @@ func TestPreloadPhotoByUID(t *testing.T) {
}
func TestMissingPhotos(t *testing.T) {
result, err := PhotosMissing(15, 0)
result, err := MissingPhotos(15, 0)
if err != nil {
t.Fatal(err)
@ -67,6 +67,22 @@ func TestMissingPhotos(t *testing.T) {
assert.LessOrEqual(t, 1, len(result))
}
func TestArchivedPhotos(t *testing.T) {
results, err := ArchivedPhotos(15, 0)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, 1, len(results))
if len(results) > 1 {
result := results[0]
assert.Equal(t, "image", result.PhotoType)
assert.Equal(t, "pt9jtdre2lvl0y25", result.PhotoUID)
}
}
func TestPhotosMetadataUpdate(t *testing.T) {
interval := entity.MetadataUpdateInterval
result, err := PhotosMetadataUpdate(10, 0, time.Second, interval)