Purge: Hide missing files in UI and set new primary if needed #917

This commit is contained in:
Michael Mayer 2021-01-24 20:40:40 +01:00
parent 4018105796
commit 274c9347f5
8 changed files with 337 additions and 203 deletions

View file

@ -1,197 +1,201 @@
<template>
<div class="p-tab p-tab-photo-files">
<v-expansion-panel expand class="pa-0 elevation-0 secondary" :value="state">
<v-expansion-panel-content v-for="(file, index) in model.fileModels()" :key="index"
class="pa-0 elevation-0 secondary-light" style="margin-top: 1px;">
<template #header>
<div class="caption">{{ file.baseName(70) }}</div>
</template>
<v-card>
<v-card-text class="white pa-0">
<v-container fluid class="pa-0">
<v-layout row wrap fill-height
align-center
justify-center>
<v-flex xs12 class="pa-0">
<div class="v-table__overflow">
<table class="v-datatable v-table theme--light photo-files">
<tbody>
<tr v-if="file.Type === 'jpg'">
<td>
<translate>Preview</translate>
</td>
<td>
<v-img :src="file.thumbnailUrl('tile_224')"
aspect-ratio="1"
max-width="112"
max-height="112"
class="accent lighten-2 elevation-0 clickable"
@click.exact="openFile(file)"
>
</v-img>
</td>
</tr>
<tr>
<td>
<translate>Actions</translate>
</td>
<td>
<v-btn small depressed dark color="primary-button" class="ma-0 action-download"
@click.stop.prevent="downloadFile(file)">
<translate>Download</translate>
</v-btn>
<v-btn v-if="features.edit && file.Type === 'jpg' && !file.Primary" small depressed dark color="primary-button"
class="ma-0 action-primary"
@click.stop.prevent="primaryFile(file)">
<template v-for="(file, index) in model.fileModels()">
<v-expansion-panel-content v-if="!file.Missing" :key="index" class="pa-0 elevation-0 secondary-light"
style="margin-top: 1px;">
<template #header>
<div class="caption">{{ file.baseName(70) }}</div>
</template>
<v-card>
<v-card-text class="white pa-0">
<v-container fluid class="pa-0">
<v-layout row wrap fill-height
align-center
justify-center>
<v-flex xs12 class="pa-0">
<div class="v-table__overflow">
<table class="v-datatable v-table theme--light photo-files">
<tbody>
<tr v-if="file.Type === 'jpg'">
<td>
<translate>Preview</translate>
</td>
<td>
<v-img :src="file.thumbnailUrl('tile_224')"
aspect-ratio="1"
max-width="112"
max-height="112"
class="accent lighten-2 elevation-0 clickable"
@click.exact="openFile(file)"
>
</v-img>
</td>
</tr>
<tr>
<td>
<translate>Actions</translate>
</td>
<td>
<v-btn small depressed dark color="primary-button" class="ma-0 action-download"
@click.stop.prevent="downloadFile(file)">
<translate>Download</translate>
</v-btn>
<v-btn v-if="features.edit && file.Type === 'jpg' && !file.Primary" small depressed dark
color="primary-button"
class="ma-0 action-primary"
@click.stop.prevent="primaryFile(file)">
<translate>Primary</translate>
</v-btn>
<v-btn v-if="features.edit && !file.Sidecar && !file.Primary && file.Root === '/'" small
depressed dark color="primary-button"
class="ma-0 action-unstack"
@click.stop.prevent="unstackFile(file)">
<translate>Unstack</translate>
</v-btn>
<v-btn v-if="features.delete && !file.Primary" small depressed dark color="primary-button"
class="ma-0 action-delete"
@click.stop.prevent="showDeleteDialog(file)">
<translate>Delete</translate>
</v-btn>
</td>
</tr>
<tr>
<td title="Unique ID">
UID
</td>
<td>{{ file.UID | uppercase }}</td>
</tr>
<tr v-if="file.InstanceID" title="XMP">
<td>
<translate>Instance ID</translate>
</td>
<td>{{ file.InstanceID | uppercase }}</td>
</tr>
<tr>
<td title="SHA-1">
<translate>Hash</translate>
</td>
<td>{{ file.Hash }}</td>
</tr>
<tr v-if="file.Root.length > 1">
<td>
<translate>Storage Folder</translate>
</td>
<td>{{ file.Root | capitalize }}</td>
</tr>
<tr v-if="file.Name">
<td>
<translate>Name</translate>
</td>
<td>{{ file.Name }}</td>
</tr>
<tr v-if="file.OriginalName">
<td>
<translate>Original Name</translate>
</td>
<td>{{ file.OriginalName }}</td>
</tr>
<tr>
<td>
<translate>Size</translate>
</td>
<td>{{ file.sizeInfo() }}</td>
</tr>
<tr v-if="file.Type">
<td>
<translate>Type</translate>
</td>
<td>{{ file.typeInfo() }}</td>
</tr>
<tr v-if="file.Codec">
<td>
<translate>Codec</translate>
</td>
<td>{{ file.Codec | uppercase }}</td>
</tr>
<tr v-if="file.Primary">
<td>
<translate>Primary</translate>
</v-btn>
<v-btn v-if="features.edit && !file.Sidecar && !file.Primary && file.Root === '/'" small depressed dark color="primary-button"
class="ma-0 action-unstack"
@click.stop.prevent="unstackFile(file)">
<translate>Unstack</translate>
</v-btn>
<v-btn v-if="features.delete && !file.Primary" small depressed dark color="primary-button"
class="ma-0 action-delete"
@click.stop.prevent="showDeleteDialog(file)">
<translate>Delete</translate>
</v-btn>
</td>
</tr>
<tr>
<td title="Unique ID">
UID
</td>
<td>{{ file.UID | uppercase }}</td>
</tr>
<tr v-if="file.InstanceID" title="XMP">
<td>
<translate>Instance ID</translate>
</td>
<td>{{ file.InstanceID | uppercase }}</td>
</tr>
<tr>
<td title="SHA-1">
<translate>Hash</translate>
</td>
<td>{{ file.Hash }}</td>
</tr>
<tr v-if="file.Root.length > 1">
<td>
<translate>Storage Folder</translate>
</td>
<td>{{ file.Root | capitalize }}</td>
</tr>
<tr v-if="file.Name">
<td>
<translate>Name</translate>
</td>
<td>{{ file.Name }}</td>
</tr>
<tr v-if="file.OriginalName">
<td>
<translate>Original Name</translate>
</td>
<td>{{ file.OriginalName }}</td>
</tr>
<tr>
<td>
<translate>Size</translate>
</td>
<td>{{ file.sizeInfo() }}</td>
</tr>
<tr v-if="file.Type">
<td>
<translate>Type</translate>
</td>
<td>{{ file.typeInfo() }}</td>
</tr>
<tr v-if="file.Codec">
<td>
<translate>Codec</translate>
</td>
<td>{{ file.Codec | uppercase }}</td>
</tr>
<tr v-if="file.Primary">
<td>
<translate>Primary</translate>
</td>
<td>
<translate>Yes</translate>
</td>
</tr>
<tr v-if="file.Portrait">
<td>
<translate>Portrait</translate>
</td>
<td>
<translate>Yes</translate>
</td>
</tr>
<tr v-if="file.Projection">
<td>
<translate>Projection</translate>
</td>
<td>{{ file.Projection | capitalize }}</td>
</tr>
<tr v-if="file.AspectRatio">
<td>
<translate>Aspect Ratio</translate>
</td>
<td>{{ file.AspectRatio }}</td>
</tr>
<tr v-if="file.MainColor">
<td>
<translate>Main Color</translate>
</td>
<td>{{ file.MainColor | capitalize }}</td>
</tr>
<tr v-if="file.Type === 'jpg'">
<td>
<translate>Chroma</translate>
</td>
<td>{{ file.Chroma }} / 100</td>
</tr>
<tr v-if="file.Error">
<td>
<translate>Error</translate>
</td>
<td>{{ file.Error }}</td>
</tr>
<tr v-if="file.Missing">
<td>
<translate>Missing</translate>
</td>
<td>
<translate>Yes</translate>
</td>
</tr>
<tr>
<td>
<translate>Added</translate>
</td>
<td>{{ formatTime(file.CreatedAt) }}
<translate>in</translate>
{{ Math.round(file.CreatedIn / 1000000) | number('0,0') }} ms
</td>
</tr>
<tr v-if="file.UpdatedIn">
<td>
<translate>Updated</translate>
</td>
<td>{{ formatTime(file.UpdatedAt) }}
<translate>in</translate>
{{ Math.round(file.UpdatedIn / 1000000) | number('0,0') }} ms
</td>
</tr>
</tbody>
</table>
</div>
</v-flex>
</v-layout>
</v-container>
</v-card-text>
</v-card>
</v-expansion-panel-content>
</td>
<td>
<translate>Yes</translate>
</td>
</tr>
<tr v-if="file.Portrait">
<td>
<translate>Portrait</translate>
</td>
<td>
<translate>Yes</translate>
</td>
</tr>
<tr v-if="file.Projection">
<td>
<translate>Projection</translate>
</td>
<td>{{ file.Projection | capitalize }}</td>
</tr>
<tr v-if="file.AspectRatio">
<td>
<translate>Aspect Ratio</translate>
</td>
<td>{{ file.AspectRatio }}</td>
</tr>
<tr v-if="file.MainColor">
<td>
<translate>Main Color</translate>
</td>
<td>{{ file.MainColor | capitalize }}</td>
</tr>
<tr v-if="file.Type === 'jpg'">
<td>
<translate>Chroma</translate>
</td>
<td>{{ file.Chroma }} / 100</td>
</tr>
<tr v-if="file.Error">
<td>
<translate>Error</translate>
</td>
<td>{{ file.Error }}</td>
</tr>
<tr v-if="file.Missing">
<td>
<translate>Missing</translate>
</td>
<td>
<translate>Yes</translate>
</td>
</tr>
<tr>
<td>
<translate>Added</translate>
</td>
<td>{{ formatTime(file.CreatedAt) }}
<translate>in</translate>
{{ Math.round(file.CreatedIn / 1000000) | number('0,0') }} ms
</td>
</tr>
<tr v-if="file.UpdatedIn">
<td>
<translate>Updated</translate>
</td>
<td>{{ formatTime(file.UpdatedAt) }}
<translate>in</translate>
{{ Math.round(file.UpdatedIn / 1000000) | number('0,0') }} ms
</td>
</tr>
</tbody>
</table>
</div>
</v-flex>
</v-layout>
</v-container>
</v-card-text>
</v-card>
</v-expansion-panel-content>
</template>
</v-expansion-panel>
<p-file-delete-dialog :show="deleteFile.dialog" @cancel="closeDeleteDialog"
@confirm="confirmDeleteFile"></p-file-delete-dialog>

View file

@ -188,9 +188,18 @@ func (m *File) Delete(permanently bool) error {
// Purge removes a file from the index by marking it as missing.
func (m *File) Purge() error {
deletedAt := Timestamp()
m.FileMissing = true
m.FilePrimary = false
return Db().Unscoped().Exec("UPDATE files SET file_missing = 1, file_primary = 0 WHERE id = ?", m.ID).Error
m.DeletedAt = &deletedAt
return UnscopedDb().Exec("UPDATE files SET file_missing = 1, file_primary = 0, deleted_at = ? WHERE id = ?", &deletedAt, m.ID).Error
}
// Found restores a previously purged file.
func (m *File) Found() error {
m.FileMissing = false
m.DeletedAt = nil
return UnscopedDb().Exec("UPDATE files SET file_missing = 0, deleted_at = NULL WHERE id = ?", m.ID).Error
}
// AllFilesMissing returns true, if all files for the photo of this file are missing.

View file

@ -1042,6 +1042,43 @@ func (m *Photo) PrimaryFile() (File, error) {
return PrimaryFile(m.PhotoUID)
}
// SetPrimary sets a new primary file.
func (m *Photo) SetPrimary(fileUID string) error {
if m.PhotoUID == "" {
return fmt.Errorf("photo uid is empty")
}
var files []string
if fileUID != "" {
// Do nothing.
} else if err := Db().Model(File{}).
Where("photo_uid = ? AND file_missing = 0 AND file_type = 'jpg'", m.PhotoUID).
Order("file_width DESC").Limit(1).
Pluck("file_uid", &files).Error; err != nil {
return err
} else if len(files) == 0 {
return fmt.Errorf("photo %s has no jpegs", m.PhotoUID)
} else {
fileUID = files[0]
}
if fileUID == "" {
return fmt.Errorf("file uid is empty")
}
Db().Model(File{}).Where("photo_uid = ? AND file_uid <> ?", m.PhotoUID, fileUID).UpdateColumn("file_primary", false)
if err := Db().Model(File{}).Where("photo_uid = ? AND file_uid = ?", m.PhotoUID, fileUID).UpdateColumn("file_primary", true).Error; err != nil {
return err
} else if m.PhotoQuality < 0 {
m.PhotoQuality = 0
return m.UpdateQuality()
}
return nil
}
// MapKey returns a key referencing time and location for indexing.
func (m *Photo) MapKey() string {
return MapKey(m.TakenAt, m.CellID)

View file

@ -910,3 +910,13 @@ func TestPhoto_Links(t *testing.T) {
assert.Equal(t, "7jxf3jfn2k", links[0].LinkToken)
})
}
func TestPhoto_SetPrimary(t *testing.T) {
t.Run("success", func(t *testing.T) {
m := PhotoFixtures.Get("19800101_000002_D640C559")
if err := m.SetPrimary(""); err != nil {
t.Fatal(err)
}
})
}

View file

@ -89,7 +89,7 @@ func (w *Purge) Start(opt PurgeOptions) (purgedFiles map[string]bool, purgedPhot
continue
}
if err := file.Update("FileMissing", false); err != nil {
if err := file.Found(); err != nil {
log.Errorf("purge: %s", err)
} else {
log.Infof("purge: found %s", txt.Quote(file.FileName))
@ -102,12 +102,23 @@ func (w *Purge) Start(opt PurgeOptions) (purgedFiles map[string]bool, purgedPhot
continue
}
wasPrimary := file.FilePrimary
if err := file.Purge(); err != nil {
log.Errorf("purge: %s", err)
} else {
w.files.Remove(file.FileName, file.FileRoot)
purgedFiles[fileName] = true
log.Infof("purge: flagged file %s as missing", txt.Quote(file.FileName))
continue
}
w.files.Remove(file.FileName, file.FileRoot)
purgedFiles[fileName] = true
log.Infof("purge: flagged file %s as missing", txt.Quote(file.FileName))
if !wasPrimary {
continue
}
if err := query.SetPhotoPrimary(file.PhotoUID, ""); err != nil {
log.Warnf("purge: %s (set new primary)", err)
}
}
}
@ -228,6 +239,12 @@ func (w *Purge) Start(opt PurgeOptions) (purgedFiles map[string]bool, purgedPhot
time.Sleep(50 * time.Millisecond)
}
log.Info("purge: searching index for unassigned primary files")
if err := query.FixPrimaries(); err != nil {
log.Errorf("purge: %s (find unassigned primaries)", err.Error())
}
log.Info("purge: searching index for hidden media files")
if err := query.ResetPhotoQuality(); err != nil {

View file

@ -103,6 +103,26 @@ func RenameFile(srcRoot, srcName, destRoot, destName string) error {
// SetPhotoPrimary sets a new primary image file for a photo.
func SetPhotoPrimary(photoUID, fileUID string) error {
if photoUID == "" {
return fmt.Errorf("photo uid is missing")
}
var files []string
if fileUID != "" {
// Do nothing.
} else if err := Db().Model(entity.File{}).Where("photo_uid = ? AND file_missing = 0 AND file_type = 'jpg'", photoUID).Order("file_width DESC").Limit(1).Pluck("file_uid", &files).Error; err != nil {
return err
} else if len(files) == 0 {
return fmt.Errorf("photo %s has no jpegs", photoUID)
} else {
fileUID = files[0]
}
if fileUID == "" {
return fmt.Errorf("file uid is missing")
}
Db().Model(entity.File{}).Where("photo_uid = ? AND file_uid <> ?", photoUID, fileUID).UpdateColumn("file_primary", false)
return Db().Model(entity.File{}).Where("photo_uid = ? AND file_uid = ?", photoUID, fileUID).UpdateColumn("file_primary", true).Error
}

View file

@ -165,15 +165,29 @@ func TestFileByHash(t *testing.T) {
}
func TestSetPhotoPrimary(t *testing.T) {
assert.Equal(t, false, entity.FileFixturesExampleXMP.FilePrimary)
t.Run("success", func(t *testing.T) {
assert.Equal(t, false, entity.FileFixturesExampleXMP.FilePrimary)
err := SetPhotoPrimary("pt9jtdre2lvl0yh7", "ft2es49whhbnlqdn")
err := SetPhotoPrimary("pt9jtdre2lvl0yh7", "ft2es49whhbnlqdn")
if err != nil {
t.Fatal(err)
}
//TODO How to assert
//assert.Equal(t, true, entity.FileFixturesExampleXMP.FilePrimary)
if err != nil {
t.Fatal(err)
}
})
t.Run("no_file_uid", func(t *testing.T) {
err := SetPhotoPrimary("pt9jtdre2lvl0yh7", "")
if err != nil {
t.Fatal(err)
}
})
t.Run("no_uid", func(t *testing.T) {
err := SetPhotoPrimary("", "")
if err == nil {
t.Fatal("error expected")
}
})
}
func TestSetFileError(t *testing.T) {

View file

@ -129,3 +129,26 @@ func PhotosOrphaned() (photos entity.Photos, err error) {
return photos, err
}
// FixPrimaries tries to set a primary file for photos that have none.
func FixPrimaries() error {
var photos entity.Photos
if err := UnscopedDb().
Raw(`SELECT * FROM photos WHERE
deleted_at IS NULL
AND id NOT IN (SELECT photo_id FROM files WHERE file_primary = true)`).
Find(&photos).Error; err != nil {
return err
}
for _, p := range photos {
log.Debugf("photo: finding new primary for %s", p.PhotoUID)
if err := p.SetPrimary(""); err != nil {
log.Warnf("photo: %s (set new primary)", err)
}
}
return nil
}