UX: Add Delete All button to archive page toolbar #272
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
parent
90eac1966b
commit
ad3da85ecb
9 changed files with 119 additions and 27 deletions
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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(), ", ")
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue