Import: Implement "add to album" in backend #246

Signed-off-by: Michael Mayer <michael@liquidbytes.net>
This commit is contained in:
Michael Mayer 2020-05-31 21:02:08 +02:00
parent a1238c94cc
commit c39ec9695f
23 changed files with 288 additions and 72 deletions

View file

@ -272,27 +272,19 @@ func AddPhotosToAlbum(router *gin.RouterGroup, conf *config.Config) {
return
}
var added []entity.PhotoAlbum
added := a.AddPhotos(photos.UIDs())
for _, p := range photos {
pa := entity.PhotoAlbum{AlbumUID: a.AlbumUID, PhotoUID: p.PhotoUID, Hidden: false}
if err := pa.Save(); err != nil {
log.Errorf("album: %s", err.Error())
if len(added) > 0 {
if len(added) == 1 {
event.Success(fmt.Sprintf("one entry added to %s", txt.Quote(a.Title())))
} else {
added = append(added, pa)
event.Success(fmt.Sprintf("%d entries added to %s", len(added), txt.Quote(a.Title())))
}
PublishAlbumEvent(EntityUpdated, a.AlbumUID, c)
}
if len(added) == 1 {
event.Success(fmt.Sprintf("one photo added to %s", txt.Quote(a.AlbumTitle)))
} else {
event.Success(fmt.Sprintf("%d photos added to %s", len(added), txt.Quote(a.AlbumTitle)))
}
PublishAlbumEvent(EntityUpdated, a.AlbumUID, c)
c.JSON(http.StatusOK, gin.H{"message": "photos added to album", "album": a, "added": added})
c.JSON(http.StatusOK, gin.H{"message": "photos added to album", "album": a, "photos": photos.UIDs(), "added": added})
})
}
@ -324,18 +316,19 @@ func RemovePhotosFromAlbum(router *gin.RouterGroup, conf *config.Config) {
return
}
for _, photoUID := range f.Photos {
pa := entity.PhotoAlbum{AlbumUID: a.AlbumUID, PhotoUID: photoUID, Hidden: true}
logError("album", pa.Save())
removed := a.RemovePhotos(f.Photos)
if len(removed) > 0 {
if len(removed) == 1 {
event.Success(fmt.Sprintf("one entry removed from %s", txt.Quote(a.Title())))
} else {
event.Success(fmt.Sprintf("%d entries removed from %s", len(removed), txt.Quote(txt.Quote(a.Title()))))
}
PublishAlbumEvent(EntityUpdated, a.AlbumUID, c)
}
// affected := entity.Db().Model(entity.PhotoAlbum{}).Where("album_uid = ? AND photo_uid IN (?)", a.AlbumUID, f.Photos).UpdateColumn("Hidden", true).RowsAffected
event.Success(fmt.Sprintf("entries removed from %s", a.AlbumTitle))
PublishAlbumEvent(EntityUpdated, a.AlbumUID, c)
c.JSON(http.StatusOK, gin.H{"message": "entries removed from album", "album": a, "photos": f.Photos})
c.JSON(http.StatusOK, gin.H{"message": "entries removed from album", "album": a, "photos": f.Photos, "removed": removed})
})
}

View file

@ -65,6 +65,11 @@ func StartImport(router *gin.RouterGroup, conf *config.Config) {
opt = photoprism.ImportOptionsCopy(path)
}
if len(f.Albums) > 0 {
log.Debugf("import: files will be added to album %s", strings.Join(f.Albums, " and "))
opt.Albums = f.Albums
}
imp.Start(opt)
if subPath != "" && path != conf.ImportPath() && fs.IsEmpty(path) {

View file

@ -19,6 +19,8 @@ const (
AccountSyncStatusSynced = "synced"
)
type Accounts []Account
// Account represents a remote service account for uploading, downloading or syncing media files.
type Account struct {
ID uint `gorm:"primary_key"`

View file

@ -1,6 +1,7 @@
package entity
import (
"fmt"
"strings"
"time"
@ -13,6 +14,8 @@ import (
"github.com/ulule/deepcopier"
)
type Albums []Album
// Album represents a photo album
type Album struct {
ID uint `gorm:"primary_key" json:"ID" yaml:"-"`
@ -40,13 +43,49 @@ type Album struct {
DeletedAt *time.Time `sql:"index" json:"-" yaml:"-"`
}
// BeforeCreate creates a random UID if needed before inserting a new row to the database.
func (m *Album) BeforeCreate(scope *gorm.Scope) error {
if rnd.IsUID(m.AlbumUID, 'a') {
// AddPhotoToAlbums adds a photo UID to multiple albums and automatically creates them if needed.
func AddPhotoToAlbums(photo string, albums []string) (err error) {
if photo == "" || len(albums) == 0 {
// Do nothing.
return nil
}
return scope.SetColumn("AlbumUID", rnd.PPID('a'))
if !rnd.IsPPID(photo, 'p') {
return fmt.Errorf("album: invalid photo uid %s", photo)
}
for _, album := range albums {
var aUID string
if album == "" {
log.Debugf("album: empty album identifier while adding photo %s", photo)
continue
}
if rnd.IsPPID(album, 'a') {
aUID = album
} else {
a := NewAlbum(album, TypeAlbum)
if err = a.Find(); err == nil {
aUID = a.AlbumUID
} else if err = a.Create(); err == nil {
aUID = a.AlbumUID
} else {
log.Errorf("album: %s (add photo %s to albums)", err.Error(), photo)
}
}
if aUID != "" {
entry := PhotoAlbum{AlbumUID: aUID, PhotoUID: photo, Hidden: false}
if err = entry.Save(); err != nil {
log.Errorf("album: %s (add photo %s to albums)", err.Error(), photo)
}
}
}
return err
}
// NewAlbum creates a new album; default name is current month and year
@ -58,7 +97,6 @@ func NewAlbum(albumTitle, albumType string) *Album {
}
result := &Album{
AlbumUID: rnd.PPID('a'),
AlbumOrder: SortOrderOldest,
AlbumType: albumType,
CreatedAt: now,
@ -79,7 +117,6 @@ func NewFolderAlbum(albumTitle, albumSlug, albumFilter string) *Album {
now := time.Now().UTC()
result := &Album{
AlbumUID: rnd.PPID('a'),
AlbumOrder: SortOrderOldest,
AlbumType: TypeFolder,
AlbumTitle: albumTitle,
@ -101,7 +138,6 @@ func NewMomentsAlbum(albumTitle, albumSlug, albumFilter string) *Album {
now := time.Now().UTC()
result := &Album{
AlbumUID: rnd.PPID('a'),
AlbumOrder: SortOrderOldest,
AlbumType: TypeMoment,
AlbumTitle: albumTitle,
@ -128,7 +164,6 @@ func NewMonthAlbum(albumTitle, albumSlug string, year, month int) *Album {
now := time.Now().UTC()
result := &Album{
AlbumUID: rnd.PPID('a'),
AlbumOrder: SortOrderOldest,
AlbumType: TypeMonth,
AlbumTitle: albumTitle,
@ -143,6 +178,59 @@ func NewMonthAlbum(albumTitle, albumSlug string, year, month int) *Album {
return result
}
// FindAlbumBySlug finds a matching album or returns nil.
func FindAlbumBySlug(slug, albumType string) *Album {
result := Album{}
if err := UnscopedDb().Where("album_slug = ? AND album_type = ?", slug, albumType).First(&result).Error; err != nil {
return nil
}
return &result
}
// Find updates the entity with values from the database.
func (m *Album) Find() error {
if rnd.IsPPID(m.AlbumUID, 'a') {
log.Debugf("IS PPID: %s", m.AlbumUID)
if err := UnscopedDb().First(m, "album_uid = ?", m.AlbumUID).Error; err != nil {
return err
}
}
if err := UnscopedDb().First(m, "album_slug = ? AND album_type = ?", m.AlbumSlug, m.AlbumType).Error; err != nil {
return err
}
return nil
}
// BeforeCreate creates a random UID if needed before inserting a new row to the database.
func (m *Album) BeforeCreate(scope *gorm.Scope) error {
if rnd.IsUID(m.AlbumUID, 'a') {
return nil
}
return scope.SetColumn("AlbumUID", rnd.PPID('a'))
}
// String returns the id or name as string.
func (m *Album) String() string {
if m.AlbumSlug != "" {
return m.AlbumSlug
}
if m.AlbumTitle != "" {
return txt.Quote(m.AlbumTitle)
}
if m.AlbumUID != "" {
return m.AlbumUID
}
return "[unknown album]"
}
// Checks if the album is of type moment.
func (m *Album) IsMoment() bool {
return m.AlbumType == TypeMoment
@ -213,13 +301,37 @@ func (m *Album) Create() error {
return nil
}
// FindAlbum finds a matching album or returns nil.
func FindAlbum(slug, albumType string) *Album {
result := Album{}
// Returns the album title.
func (m *Album) Title() string {
return m.AlbumTitle
}
if err := UnscopedDb().Where("album_slug = ? AND album_type = ?", slug, albumType).First(&result).Error; err != nil {
return nil
// AddPhotos adds photos to an existing album.
func (m *Album) AddPhotos(UIDs []string) (added []PhotoAlbum) {
for _, uid := range UIDs {
entry := PhotoAlbum{AlbumUID: m.AlbumUID, PhotoUID: uid, Hidden: false}
if err := entry.Save(); err != nil {
log.Errorf("album: %s (add to album %s)", err.Error(), m)
} else {
added = append(added, entry)
}
}
return &result
return added
}
// RemovePhotos removes photos from an album.
func (m *Album) RemovePhotos(UIDs []string) (removed []PhotoAlbum) {
for _, uid := range UIDs {
entry := PhotoAlbum{AlbumUID: m.AlbumUID, PhotoUID: uid, Hidden: true}
if err := entry.Save(); err != nil {
log.Errorf("album: %s (remove from album %s)", err.Error(), m)
} else {
removed = append(removed, entry)
}
}
return removed
}

View file

@ -92,6 +92,24 @@ var AlbumFixtures = AlbumMap{
UpdatedAt: time.Date(2020, 2, 1, 0, 0, 0, 0, time.UTC),
DeletedAt: nil,
},
"import": {
ID: 1000004,
CoverUID: "",
AlbumUID: "at6axuzitogaaiax",
AlbumSlug: "import",
AlbumType: TypeAlbum,
AlbumTitle: "Import Album",
AlbumDescription: "",
AlbumNotes: "",
AlbumOrder: "name",
AlbumTemplate: "",
AlbumFilter: "",
AlbumFavorite: false,
Links: []Link{},
CreatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2020, 2, 1, 0, 0, 0, 0, time.UTC),
DeletedAt: nil,
},
}
// CreateAlbumFixtures inserts known entities into the database for testing.

View file

@ -89,3 +89,31 @@ func TestAlbum_Save(t *testing.T) {
})
}
func TestAddPhotoToAlbums(t *testing.T) {
t.Run("success", func(t *testing.T) {
err := AddPhotoToAlbums("pt9jtxrexxvl0yh0", []string{"at6axuzitogaaiax"})
if err != nil {
t.Fatal(err)
}
a := Album{AlbumUID: "at6axuzitogaaiax"}
if err := a.Find(); err != nil {
t.Fatal(err)
}
var entries []PhotoAlbum
if err := Db().Where("album_uid = ? AND photo_uid = ?", "at6axuzitogaaiax", "pt9jtxrexxvl0yh0").Find(&entries).Error; err != nil {
t.Fatal(err)
}
if len(entries) < 1 {
t.Error("at least one album entry expected")
}
// t.Logf("photo album entries: %+v", entries)
})
}

View file

@ -41,4 +41,8 @@ const (
RootDefault = ""
RootImport = "import"
RootPath = "/"
Updated = "updated"
Created = "created"
Deleted = "deleted"
)

View file

@ -12,6 +12,8 @@ import (
"github.com/ulule/deepcopier"
)
type Files []File
// File represents an image or sidecar file that belongs to a photo.
type File struct {
ID uint `gorm:"primary_key" json:"-" yaml:"-"`

View file

@ -15,6 +15,8 @@ import (
"github.com/ulule/deepcopier"
)
type Folders []Folder
// Folder represents a file system directory.
type Folder struct {
Path string `gorm:"type:varbinary(255);unique_index:idx_folders_path_root;" json:"Path" yaml:"Path"`

View file

@ -17,6 +17,20 @@ import (
"github.com/ulule/deepcopier"
)
// Default photo result slice for simple use cases.
type Photos []Photo
// UIDs returns a slice of unique photo IDs.
func (m Photos) UIDs() []string {
result := make([]string, len(m))
for i, el := range m {
result[i] = el.PhotoUID
}
return result
}
// Photo represents a photo, all its properties, and link to all its images and sidecar files.
type Photo struct {
ID uint `gorm:"primary_key" yaml:"-"`
@ -132,7 +146,7 @@ func SavePhotoForm(model Photo, form form.Photo, geoApi string) error {
return nil
}
// String returns an entity identifier as string for use in logs.
// String returns the id or name as string.
func (m *Photo) String() string {
if m.PhotoUID == "" {
if m.PhotoName != "" {

View file

@ -1,6 +1,7 @@
package form
type ImportOptions struct {
Path string `json:"path"`
Move bool `json:"move"`
Albums []string `json:"albums"`
Path string `json:"path"`
Move bool `json:"move"`
}

View file

@ -1,6 +1,7 @@
package photoprism
type ImportOptions struct {
Albums []string
Path string
Move bool
RemoveDotFiles bool

View file

@ -5,6 +5,7 @@ import (
"path"
"path/filepath"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt"
@ -129,6 +130,13 @@ func ImportWorker(jobs <-chan ImportJob) {
}
res := ind.MediaFile(related.Main, indexOpt, originalName)
if res.Success() {
if err := entity.AddPhotoToAlbums(res.PhotoUID, opt.Albums); err != nil {
log.Warn(err)
}
}
log.Infof("import: %s main %s file %s", res, related.Main.FileType(), txt.Quote(related.Main.RelativeName(ind.originalsPath())))
done[related.Main.FileName()] = true
} else {
@ -149,6 +157,8 @@ func ImportWorker(jobs <-chan ImportJob) {
log.Infof("import: %s related %s file %s", res, f.FileType(), txt.Quote(f.RelativeName(ind.originalsPath())))
}
}
}
}

View file

@ -75,7 +75,7 @@ func (m *Moments) Start() (err error) {
Path: mom.Path,
}
if a := entity.FindAlbum(mom.Slug(), entity.TypeFolder); a != nil {
if a := entity.FindAlbumBySlug(mom.Slug(), entity.TypeFolder); a != nil {
if a.DeletedAt != nil {
// Nothing to do.
log.Debugf("moments: %s was deleted (%s)", txt.Quote(a.AlbumTitle), a.AlbumFilter)
@ -101,7 +101,7 @@ func (m *Moments) Start() (err error) {
log.Errorf("moments: %s", err.Error())
} else {
for _, mom := range results {
if a := entity.FindAlbum(mom.Slug(), entity.TypeMonth); a != nil {
if a := entity.FindAlbumBySlug(mom.Slug(), entity.TypeMonth); a != nil {
if a.DeletedAt != nil {
// Nothing to do.
log.Debugf("moments: %s was deleted (%s)", txt.Quote(a.AlbumTitle), a.AlbumFilter)
@ -128,7 +128,7 @@ func (m *Moments) Start() (err error) {
Year: mom.Year,
}
if a := entity.FindAlbum(mom.Slug(), entity.TypeMoment); a != nil {
if a := entity.FindAlbumBySlug(mom.Slug(), entity.TypeMoment); a != nil {
if a.DeletedAt != nil {
// Nothing to do.
log.Debugf("moments: %s was deleted (%s)", txt.Quote(a.AlbumTitle), a.AlbumFilter)
@ -158,7 +158,7 @@ func (m *Moments) Start() (err error) {
State: mom.State,
}
if a := entity.FindAlbum(mom.Slug(), entity.TypeMoment); a != nil {
if a := entity.FindAlbumBySlug(mom.Slug(), entity.TypeMoment); a != nil {
if a.DeletedAt != nil {
// Nothing to do.
log.Debugf("moments: %s was deleted (%s)", txt.Quote(a.AlbumTitle), a.AlbumFilter)
@ -186,7 +186,7 @@ func (m *Moments) Start() (err error) {
Label: mom.Label,
}
if a := entity.FindAlbum(mom.Slug(), entity.TypeMoment); a != nil {
if a := entity.FindAlbumBySlug(mom.Slug(), entity.TypeMoment); a != nil {
log.Debugf("moments: %s already exists (%s)", txt.Quote(mom.Title()), f.Serialize())
if f.Serialize() == a.AlbumFilter || a.DeletedAt != nil {

View file

@ -5,10 +5,8 @@ import (
"github.com/photoprism/photoprism/internal/form"
)
type Accounts []entity.Account
// AccountSearch returns a list of accounts.
func AccountSearch(f form.AccountSearch) (result Accounts, err error) {
func AccountSearch(f form.AccountSearch) (result entity.Accounts, err error) {
s := Db().Where(&entity.Account{})
if f.Share {

View file

@ -6,7 +6,7 @@ import (
)
// AccountUploads a list of files for uploading to a remote account.
func AccountUploads(a entity.Account, limit int) (results []entity.File, err error) {
func AccountUploads(a entity.Account, limit int) (results entity.Files, err error) {
s := Db().Where("files.file_missing = 0").
Where("files.id NOT IN (SELECT file_id FROM files_sync WHERE file_id > 0 AND account_id = ?)", a.ID)

View file

@ -6,10 +6,8 @@ import (
"github.com/photoprism/photoprism/internal/entity"
)
type Files []entity.File
// FilesByPath returns a slice of files in a given originals folder.
func FilesByPath(rootName, pathName string) (files Files, err error) {
func FilesByPath(rootName, pathName string) (files entity.Files, err error) {
if strings.HasPrefix(pathName, "/") {
pathName = pathName[1:]
}
@ -26,7 +24,7 @@ func FilesByPath(rootName, pathName string) (files Files, err error) {
}
// ExistingFiles returns not-missing and not-deleted file entities in the range of limit and offset sorted by id.
func ExistingFiles(limit int, offset int, pathName string) (files Files, err error) {
func ExistingFiles(limit int, offset int, pathName string) (files entity.Files, err error) {
if strings.HasPrefix(pathName, "/") {
pathName = pathName[1:]
}
@ -43,7 +41,7 @@ func ExistingFiles(limit int, offset int, pathName string) (files Files, err err
}
// FilesByUID
func FilesByUID(u []string, limit int, offset int) (files Files, err error) {
func FilesByUID(u []string, limit int, offset int) (files entity.Files, err error) {
if err := Db().Where("(photo_uid IN (?) AND file_primary = 1) OR file_uid IN (?)", u, u).Preload("Photo").Limit(limit).Offset(offset).Find(&files).Error; err != nil {
return files, err
}

View file

@ -7,17 +7,15 @@ import (
"github.com/photoprism/photoprism/pkg/fs"
)
type Folders []entity.Folder
// FoldersByPath returns a slice of folders in a given directory incl sub directories in recursive mode.
func FoldersByPath(rootName, rootPath, path string, recursive bool) (folders Folders, err error) {
func FoldersByPath(rootName, rootPath, path string, recursive bool) (folders entity.Folders, err error) {
dirs, err := fs.Dirs(filepath.Join(rootPath, path), recursive)
if err != nil {
return folders, err
}
folders = make(Folders, len(dirs))
folders = make(entity.Folders, len(dirs))
for i, dir := range dirs {
folder := entity.FindFolder(rootName, filepath.Join(path, dir))
@ -39,7 +37,7 @@ func FoldersByPath(rootName, rootPath, path string, recursive bool) (folders Fol
}
// AlbumFolders returns folders that should be added as album.
func AlbumFolders(threshold int) (folders Folders, err error) {
func AlbumFolders(threshold int) (folders entity.Folders, err error) {
db := UnscopedDb().Table("folders").
Select("folders.*, COUNT(photos.id) AS photo_count").
Joins("JOIN photos ON photos.photo_path = folders.path AND photos.deleted_at IS NULL AND photos.photo_quality >= 3").

View file

@ -73,7 +73,7 @@ func PhotoPreloadByUID(photoUID string) (photo entity.Photo, err error) {
}
// PhotosMissing returns photo entities without existing files.
func PhotosMissing(limit int, offset int) (entities Photos, err error) {
func PhotosMissing(limit int, offset int) (entities entity.Photos, err error) {
err = Db().
Select("photos.*").
Joins("JOIN files a ON photos.id = a.photo_id ").
@ -94,7 +94,7 @@ func ResetPhotoQuality() error {
}
// PhotosMaintenance returns photos selected for maintenance.
func PhotosMaintenance(limit int, offset int) (entities Photos, err error) {
func PhotosMaintenance(limit int, offset int) (entities entity.Photos, err error) {
err = Db().
Preload("Labels", func(db *gorm.DB) *gorm.DB {
return db.Order("photos_labels.uncertainty ASC, photos_labels.label_id DESC")

View file

@ -11,8 +11,6 @@ import (
"github.com/ulule/deepcopier"
)
// Default photo result slice for simple use cases.
type Photos []entity.Photo
// PhotoResult contains found photos and their main file plus other meta data.
type PhotoResult struct {

View file

@ -4,11 +4,12 @@ import (
"errors"
"fmt"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/form"
)
// PhotoSelection queries all selected photos.
func PhotoSelection(f form.Selection) (results Photos, err error) {
func PhotoSelection(f form.Selection) (results entity.Photos, err error) {
if f.Empty() {
return results, errors.New("no items selected")
}
@ -47,7 +48,7 @@ func PhotoSelection(f form.Selection) (results Photos, err error) {
}
// FileSelection queries all selected files e.g. for downloading.
func FileSelection(f form.Selection) (results Files, err error) {
func FileSelection(f form.Selection) (results entity.Files, err error) {
if f.Empty() {
return results, errors.New("no items selected")
}

View file

@ -3,6 +3,7 @@ package query
import (
"testing"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/form"
"github.com/stretchr/testify/assert"
)
@ -30,7 +31,7 @@ func TestPhotoSelection(t *testing.T) {
}
assert.Equal(t, 2, len(r))
assert.IsType(t, Photos{}, r)
assert.IsType(t, entity.Photos{}, r)
})
}
@ -57,6 +58,6 @@ func TestFileSelection(t *testing.T) {
}
assert.Equal(t, 3, len(r))
assert.IsType(t, Files{}, r)
assert.IsType(t, entity.Files{}, r)
})
}

View file

@ -21,13 +21,43 @@ func IsPPID(s string, prefix byte) bool {
return false
}
return s[0] == prefix
return s[0] == prefix && IsLowerAlnum(s)
}
// IsHex returns true if the string only contains hex numbers, dashes and letters without whitespace.
func IsHex(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if (r < 48 || r > 57) && (r < 97 || r > 102) && (r < 65 || r > 90) && r != 45{
return false
}
}
return true
}
// IsLowerAlnum returns true if the string only contains alphanumeric ascii chars without whitespace.
func IsLowerAlnum(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if (r < 48 || r > 57) && (r < 97 || r > 122) {
return false
}
}
return true
}
// IsUID returns true if string is a seemingly unique id.
func IsUID(s string, prefix byte) bool {
// Regular UUID.
if len(s) == 36 {
if len(s) == 36 && IsHex(s) {
return true
}