From bf592bdf7cacee75fcd2afdb14d5256f8a841169 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Fri, 5 Feb 2021 16:32:08 +0100 Subject: [PATCH] Backup: Restore archive flag from yaml files #912 --- internal/api/batch.go | 151 +++++++++++++++---------- internal/entity/photo.go | 50 ++++---- internal/entity/photo_yaml.go | 3 + internal/entity/save.go | 55 +++++++++ internal/photoprism/index_mediafile.go | 10 -- 5 files changed, 177 insertions(+), 92 deletions(-) create mode 100644 internal/entity/save.go diff --git a/internal/api/batch.go b/internal/api/batch.go index 207c34b96..b403cb937 100644 --- a/internal/api/batch.go +++ b/internal/api/batch.go @@ -38,19 +38,31 @@ func BatchPhotosArchive(router *gin.RouterGroup) { return } - log.Infof("archive: adding %s", f.String()) + log.Infof("photos: archiving %s", f.String()) - // Soft delete by setting deleted_at to current date. - err := entity.Db().Where("photo_uid IN (?)", f.Photos).Delete(&entity.Photo{}).Error + if service.Config().BackupYaml() { + photos, err := query.PhotoSelection(f) - if err != nil { + if err != nil { + AbortEntityNotFound(c) + return + } + + for _, p := range photos { + if err := p.Archive(); err != nil { + log.Errorf("archive: %s", err) + } else { + SavePhotoAsYaml(p) + } + } + } else if err := entity.Db().Where("photo_uid IN (?)", f.Photos).Delete(&entity.Photo{}).Error; err != nil { + log.Errorf("archive: %s", err) AbortSaveFailed(c) return + } else if err := entity.Db().Model(&entity.PhotoAlbum{}).Where("photo_uid IN (?)", f.Photos).UpdateColumn("hidden", true).Error; err != nil { + log.Errorf("archive: %s", err) } - // Remove archived photos from albums. - logError("archive", entity.Db().Model(&entity.PhotoAlbum{}).Where("photo_uid IN (?)", f.Photos).UpdateColumn("hidden", true).Error) - if err := entity.UpdatePhotoCounts(); err != nil { log.Errorf("photos: %s", err) } @@ -63,6 +75,64 @@ func BatchPhotosArchive(router *gin.RouterGroup) { }) } +// POST /api/v1/batch/photos/restore +func BatchPhotosRestore(router *gin.RouterGroup) { + router.POST("/batch/photos/restore", func(c *gin.Context) { + s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionDelete) + + if s.Invalid() { + AbortUnauthorized(c) + return + } + + var f form.Selection + + if err := c.BindJSON(&f); err != nil { + AbortBadRequest(c) + return + } + + if len(f.Photos) == 0 { + Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected) + return + } + + log.Infof("photos: restoring %s", f.String()) + + if service.Config().BackupYaml() { + photos, err := query.PhotoSelection(f) + + if err != nil { + AbortEntityNotFound(c) + return + } + + for _, p := range photos { + if err := p.Restore(); err != nil { + log.Errorf("restore: %s", err) + } else { + SavePhotoAsYaml(p) + } + } + } else if err := entity.Db().Unscoped().Model(&entity.Photo{}).Where("photo_uid IN (?)", f.Photos). + UpdateColumn("deleted_at", gorm.Expr("NULL")).Error; err != nil { + log.Errorf("restore: %s", err) + AbortSaveFailed(c) + return + } + + if err := entity.UpdatePhotoCounts(); err != nil { + log.Errorf("photos: %s", err) + } + + UpdateClientConfig() + + event.EntitiesRestored("photos", f.Photos) + + c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgSelectionRestored)) + }) +} + // POST /api/v1/batch/photos/approve func BatchPhotosApprove(router *gin.RouterGroup) { router.POST("batch/photos/approve", func(c *gin.Context) { @@ -98,7 +168,7 @@ func BatchPhotosApprove(router *gin.RouterGroup) { for _, p := range photos { if err := p.Approve(); err != nil { - log.Errorf("photo: %s (approve)", err.Error()) + log.Errorf("approve: %s", err) } else { approved = append(approved, p) SavePhotoAsYaml(p) @@ -113,50 +183,6 @@ func BatchPhotosApprove(router *gin.RouterGroup) { }) } -// POST /api/v1/batch/photos/restore -func BatchPhotosRestore(router *gin.RouterGroup) { - router.POST("/batch/photos/restore", func(c *gin.Context) { - s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionDelete) - - if s.Invalid() { - AbortUnauthorized(c) - return - } - - var f form.Selection - - if err := c.BindJSON(&f); err != nil { - AbortBadRequest(c) - return - } - - if len(f.Photos) == 0 { - Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected) - return - } - - log.Infof("archive: restoring %s", f.String()) - - err := entity.Db().Unscoped().Model(&entity.Photo{}).Where("photo_uid IN (?)", f.Photos). - UpdateColumn("deleted_at", gorm.Expr("NULL")).Error - - if err != nil { - AbortSaveFailed(c) - return - } - - if err := entity.UpdatePhotoCounts(); err != nil { - log.Errorf("photos: %s", err) - } - - UpdateClientConfig() - - event.EntitiesRestored("photos", f.Photos) - - c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgSelectionRestored)) - }) -} - // POST /api/v1/batch/albums/delete func BatchAlbumsDelete(router *gin.RouterGroup) { router.POST("/batch/albums/delete", func(c *gin.Context) { @@ -214,12 +240,11 @@ func BatchPhotosPrivate(router *gin.RouterGroup) { return } - log.Infof("photos: mark %s as private", f.String()) + log.Infof("photos: updating private flag for %s", f.String()) - err := entity.Db().Model(entity.Photo{}).Where("photo_uid IN (?)", f.Photos).UpdateColumn("photo_private", - gorm.Expr("CASE WHEN photo_private > 0 THEN 0 ELSE 1 END")).Error - - if err != nil { + if err := entity.Db().Model(entity.Photo{}).Where("photo_uid IN (?)", f.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 } @@ -228,8 +253,12 @@ func BatchPhotosPrivate(router *gin.RouterGroup) { log.Errorf("photos: %s", err) } - if entities, err := query.PhotoSelection(f); err == nil { - event.EntitiesUpdated("photos", entities) + if photos, err := query.PhotoSelection(f); err == nil { + for _, p := range photos { + SavePhotoAsYaml(p) + } + + event.EntitiesUpdated("photos", photos) } UpdateClientConfig() @@ -313,7 +342,7 @@ func BatchPhotosDelete(router *gin.RouterGroup) { return } - log.Infof("archive: permanently deleting %s", f.String()) + log.Infof("photos: deleting %s", f.String()) photos, err := query.PhotoSelection(f) @@ -327,7 +356,7 @@ func BatchPhotosDelete(router *gin.RouterGroup) { // Delete photos. for _, p := range photos { if err := photoprism.Delete(p); err != nil { - log.Errorf("photo: %s (delete)", err.Error()) + log.Errorf("delete: %s", err) } else { deleted = append(deleted, p) } diff --git a/internal/entity/photo.go b/internal/entity/photo.go index 8011de4f2..6b6787bba 100644 --- a/internal/entity/photo.go +++ b/internal/entity/photo.go @@ -62,7 +62,7 @@ type Photo struct { PhotoPrivate bool `json:"Private" yaml:"Private,omitempty"` PhotoScan bool `json:"Scan" yaml:"Scan,omitempty"` PhotoPanorama bool `json:"Panorama" yaml:"Panorama,omitempty"` - TimeZone string `gorm:"type:VARBINARY(64);" json:"TimeZone" yaml:"-"` + TimeZone string `gorm:"type:VARBINARY(64);" json:"TimeZone" yaml:"TimeZone,omitempty"` PlaceID string `gorm:"type:VARBINARY(42);index;default:'zz'" json:"PlaceID" yaml:"-"` PlaceSrc string `gorm:"type:VARBINARY(8);" json:"PlaceSrc" yaml:"PlaceSrc,omitempty"` CellID string `gorm:"type:VARBINARY(42);index;default:'zz'" json:"CellID" yaml:"-"` @@ -78,7 +78,7 @@ type Photo struct { PhotoExposure string `gorm:"type:VARBINARY(64);" json:"Exposure" yaml:"Exposure,omitempty"` PhotoFNumber float32 `gorm:"type:FLOAT;" json:"FNumber" yaml:"FNumber,omitempty"` PhotoFocalLength int `json:"FocalLength" yaml:"FocalLength,omitempty"` - PhotoQuality int `gorm:"type:SMALLINT" json:"Quality" yaml:"-"` + PhotoQuality int `gorm:"type:SMALLINT" json:"Quality" yaml:"Quality,omitempty"` PhotoResolution int `gorm:"type:SMALLINT" json:"Resolution" yaml:"-"` PhotoColor uint8 `json:"Color" yaml:"-"` CameraID uint `gorm:"index:idx_photos_camera_lens;default:1" json:"CameraID" yaml:"-"` @@ -238,13 +238,7 @@ func (m *Photo) Save() error { photoMutex.Lock() defer photoMutex.Unlock() - if err := UnscopedDb().Save(m).Error; err == nil { - // Nothing to do. - } else if !strings.Contains(strings.ToLower(err.Error()), "lock") { - log.Debugf("photo: %s (save %s)", err, m.PhotoUID) - return err - } else if err := UnscopedDb().Save(m).Error; err != nil { - log.Debugf("photo: %s (save %s after deadlock)", err, m.PhotoUID) + if err := Save(m, "ID"); err == nil { return err } @@ -454,17 +448,6 @@ func (m *Photo) PreloadFiles() { logError(q.Scan(&m.Files)) } -/* func (m *Photo) PreloadLabels() { - q := Db().NewScope(nil).DB(). - Table("labels"). - Select(`labels.*`). - Joins("JOIN photos_labels ON photos_labels.label_id = labels.id AND photos_labels.photo_id = ?", m.ID). - Where("labels.deleted_at IS NULL"). - Order("labels.label_name ASC") - - logError(q.Scan(&m.Labels)) -} */ - // PreloadKeywords prepares gorm scope to retrieve photo keywords func (m *Photo) PreloadKeywords() { q := Db().NewScope(nil).DB(). @@ -491,7 +474,6 @@ func (m *Photo) PreloadAlbums() { // PreloadMany prepares gorm scope to retrieve photo file, albums and keywords func (m *Photo) PreloadMany() { m.PreloadFiles() - // m.PreloadLabels() m.PreloadKeywords() m.PreloadAlbums() } @@ -980,6 +962,32 @@ func (m *Photo) AllFiles() (files Files) { return files } +// Archive removes the photo from albums and flags it as archived (soft delete). +func (m *Photo) Archive() error { + deletedAt := Timestamp() + + if err := Db().Model(&PhotoAlbum{}).Where("photo_uid = ?", m.PhotoUID).UpdateColumn("hidden", true).Error; err != nil { + return err + } else if err := m.Update("deleted_at", deletedAt); err != nil { + return err + } + + m.DeletedAt = &deletedAt + + return nil +} + +// Restore removes the archive flag (undo soft delete). +func (m *Photo) Restore() error { + if err := m.Update("deleted_at", gorm.Expr("NULL")); err != nil { + return err + } + + m.DeletedAt = nil + + return nil +} + // Delete deletes the entity from the database. func (m *Photo) Delete(permanently bool) error { if permanently { diff --git a/internal/entity/photo_yaml.go b/internal/entity/photo_yaml.go index eba7dbded..da67a9cd5 100644 --- a/internal/entity/photo_yaml.go +++ b/internal/entity/photo_yaml.go @@ -14,6 +14,9 @@ var photoYamlMutex = sync.Mutex{} // Yaml returns photo data as YAML string. func (m *Photo) Yaml() ([]byte, error) { + // Load details if not done yet. + m.GetDetails() + out, err := yaml.Marshal(m) if err != nil { diff --git a/internal/entity/save.go b/internal/entity/save.go new file mode 100644 index 000000000..9c26cf67d --- /dev/null +++ b/internal/entity/save.go @@ -0,0 +1,55 @@ +package entity + +import ( + "fmt" + "reflect" + "strings" +) + +// Save updates an entity in the database, or inserts if it doesn't exist. +func Save(m interface{}, primaryKeys ...string) (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("save: %s (panic)", r) + } + }() + + if err := Update(m, primaryKeys...); err == nil { + return nil + } else if err := UnscopedDb().Save(m).Error; err == nil { + return nil + } else if !strings.Contains(strings.ToLower(err.Error()), "lock") { + return err + } else if err := UnscopedDb().Save(m).Error; err != nil { + return err + } + + return nil +} + +// Update updates an existing entity in the database. +func Update(m interface{}, primaryKeys ...string) (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("update: %s (panic)", r) + } + }() + + v := reflect.ValueOf(m).Elem() + + for _, k := range primaryKeys { + if field := v.FieldByName(k); field.IsZero() { + return fmt.Errorf("key '%s' not found", k) + } + } + + if res := UnscopedDb().Model(m).Omit(primaryKeys...).Updates(m); res.Error != nil { + return res.Error + } else if res.RowsAffected == 0 { + return fmt.Errorf("no entity found for updating") + } else if res.RowsAffected > 1 { + log.Warnf("update: more than one row affected - bug?") + } + + return nil +} diff --git a/internal/photoprism/index_mediafile.go b/internal/photoprism/index_mediafile.go index fd094a73e..48a58c67d 100644 --- a/internal/photoprism/index_mediafile.go +++ b/internal/photoprism/index_mediafile.go @@ -285,16 +285,6 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) ( if photo.PhotoQuality == -1 && (file.FilePrimary || fileChanged) { // Restore photos that have been purged automatically. photo.DeletedAt = nil - } else if photo.DeletedAt != nil { - // Don't waste time indexing deleted / archived photos. - result.Status = IndexArchived - - // Remove missing flag from file. - if err = file.Undelete(); err != nil { - log.Errorf("index: %s in %s (undelete)", err.Error(), logName) - } - - return result } // Handle file types.