Unstack all types, except primary and sidecar files #394

Signed-off-by: Michael Mayer <michael@liquidbytes.net>
This commit is contained in:
Michael Mayer 2020-07-14 11:00:49 +02:00
parent 0cb609fc87
commit 8989c987a2
36 changed files with 596 additions and 290 deletions

View file

@ -41,13 +41,13 @@
</v-img>
</td>
</tr>
<tr v-if="file.Type === 'jpg' && !file.Primary">
<tr v-if="!file.Sidecar && !file.Primary && !file.Root">
<td>
<translate>Actions</translate>
</td>
<td>
<v-btn small depressed dark color="secondary-dark" class="ma-0 action-primary"
@click.stop.prevent="primary(file)">
@click.stop.prevent="primary(file)" v-if="file.Type === 'jpg' && !file.Primary">
<translate>Primary</translate>
</v-btn>
<v-btn small depressed dark color="secondary-dark" class="ma-0 action-unstack"

View file

@ -28,7 +28,7 @@ func SavePhotoAsYaml(p entity.Photo) {
if err := p.SaveAsYaml(yamlFile); err != nil {
log.Errorf("photo: %s (update yaml)", err)
} else {
log.Infof("photo: updated yaml file %s", txt.Quote(fs.Rel(yamlFile, conf.OriginalsPath())))
log.Infof("photo: updated yaml file %s", txt.Quote(fs.RelName(yamlFile, conf.OriginalsPath())))
}
}
}
@ -297,7 +297,7 @@ func DislikePhoto(router *gin.RouterGroup) {
// Parameters:
// uid: string PhotoUID as returned by the API
// file_uid: string File UID as returned by the API
func PhotoFilePrimary(router *gin.RouterGroup) {
func PhotoPrimary(router *gin.RouterGroup) {
router.POST("/photos/:uid/files/:file_uid/primary", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionUpdate)

View file

@ -112,10 +112,10 @@ func TestDislikePhoto(t *testing.T) {
})
}
func TestSetPhotoPrimary(t *testing.T) {
func TestPhotoPrimary(t *testing.T) {
t.Run("existing photo", func(t *testing.T) {
app, router, _ := NewApiTest()
PhotoFilePrimary(router)
PhotoPrimary(router)
r := PerformRequest(app, "POST", "/api/v1/photos/pt9jtdre2lvl0yh8/files/ft1es39w45bnlqdw/primary")
assert.Equal(t, http.StatusOK, r.Code)
GetFile(router)
@ -129,7 +129,7 @@ func TestSetPhotoPrimary(t *testing.T) {
t.Run("wrong photo uid", func(t *testing.T) {
app, router, _ := NewApiTest()
PhotoFilePrimary(router)
PhotoPrimary(router)
r := PerformRequest(app, "POST", "/api/v1/photos/xxx/files/ft1es39w45bnlqdw/primary")
val := gjson.Get(r.Body.String(), "error")
assert.Equal(t, i18n.Msg(i18n.ErrEntityNotFound), val.String())

View file

@ -2,6 +2,7 @@ package api
import (
"net/http"
"path/filepath"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
@ -11,6 +12,7 @@ import (
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/txt"
)
// POST /api/v1/photos/:uid/files/:file_uid/unstack
@ -18,7 +20,7 @@ import (
// Parameters:
// uid: string Photo UID as returned by the API
// file_uid: string File UID as returned by the API
func PhotoFileUnstack(router *gin.RouterGroup) {
func PhotoUnstack(router *gin.RouterGroup) {
router.POST("/photos/:uid/files/:file_uid/unstack", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionUpdate)
@ -27,6 +29,8 @@ func PhotoFileUnstack(router *gin.RouterGroup) {
return
}
conf := service.Config()
photoUID := c.Param("uid")
fileUID := c.Param("file_uid")
@ -42,13 +46,68 @@ func PhotoFileUnstack(router *gin.RouterGroup) {
log.Errorf("photo: can't unstack primary file")
AbortBadRequest(c)
return
} else if file.FileSidecar {
log.Errorf("photo: can't unstack sidecar files")
AbortBadRequest(c)
return
} else if file.FileRoot != entity.RootOriginals {
log.Errorf("photo: only originals can be unstacked")
AbortBadRequest(c)
return
}
fileName := photoprism.FileName(file.FileRoot, file.FileName)
baseName := filepath.Base(fileName)
mediaFile, err := photoprism.NewMediaFile(fileName)
if err != nil {
log.Errorf("photo: %s (unstack %s)", err, txt.Quote(baseName))
AbortEntityNotFound(c)
return
}
related, err := mediaFile.RelatedFiles(true)
if err != nil {
log.Errorf("photo: %s (unstack %s)", err, txt.Quote(baseName))
AbortEntityNotFound(c)
return
} else if related.Len() == 0 {
log.Errorf("photo: no related files found (unstack %s)", txt.Quote(baseName))
AbortEntityNotFound(c)
return
}
if related.Len() > 1 {
if conf.ReadOnly() {
log.Errorf("photo: can't rename files in read only mode (unstack %s)", txt.Quote(baseName))
AbortFeatureDisabled(c)
return
}
newName := mediaFile.AbsPrefix(true) + "_" + mediaFile.Checksum() + mediaFile.Extension()
if err := mediaFile.Move(newName); err != nil {
log.Errorf("photo: can't rename %s to %s (unstack)", txt.Quote(baseName), txt.Quote(filepath.Base(newName)))
AbortUnexpected(c)
return
}
}
oldPhoto := *file.Photo
oldPrimary, err := oldPhoto.PrimaryFile()
if err != nil {
log.Errorf("photo: can't find primary file for existing photo (unstack %s)", txt.Quote(baseName))
AbortUnexpected(c)
return
}
existingPhoto := *file.Photo
newPhoto := entity.NewPhoto()
if err := newPhoto.Create(); err != nil {
log.Errorf("photo: %s (unstack)", err.Error())
log.Errorf("photo: %s (unstack %s)", err.Error(), txt.Quote(baseName))
AbortSaveFailed(c)
return
}
@ -56,29 +115,46 @@ func PhotoFileUnstack(router *gin.RouterGroup) {
file.Photo = &newPhoto
file.PhotoID = newPhoto.ID
file.PhotoUID = newPhoto.PhotoUID
file.FileName = mediaFile.RelName(conf.OriginalsPath())
if err := file.Save(); err != nil {
log.Errorf("photo: %s (unstack)", err.Error())
log.Errorf("photo: %s (unstack %s)", err.Error(), txt.Quote(baseName))
if err := newPhoto.Delete(true); err != nil {
log.Errorf("photo: %s (unstack %s)", err.Error(), txt.Quote(baseName))
}
AbortSaveFailed(c)
return
}
fileName := photoprism.FileName(file.FileRoot, file.FileName)
ind := service.Index()
f, err := photoprism.NewMediaFile(fileName)
if err != nil {
log.Errorf("photo: %s (unstack)", err)
AbortEntityNotFound(c)
return
}
if err := service.Index().MediaFile(f, photoprism.IndexOptions{Rescan: true}, existingPhoto.OriginalName).Error; err != nil {
log.Errorf("photo: %s (unstack)", err)
// Index new, unstacked file.
if res := ind.File(mediaFile.FileName()); res.Failed() {
log.Errorf("photo: %s (unstack %s)", res.Err, txt.Quote(baseName))
AbortSaveFailed(c)
return
}
// Reset type for old, existing photo to image.
if err := oldPhoto.Update("PhotoType", entity.TypeImage); err != nil {
log.Errorf("photo: %s (unstack %s)", err, txt.Quote(baseName))
AbortUnexpected(c)
return
}
// Get name of old, existing primary file.
oldPrimaryName := photoprism.FileName(oldPrimary.FileRoot, oldPrimary.FileName)
// Re-index old, existing primary file.
if res := ind.File(oldPrimaryName); res.Failed() {
log.Errorf("photo: %s (unstack %s)", res.Err, txt.Quote(baseName))
AbortSaveFailed(c)
return
}
// Notify clients by publishing events.
PublishPhotoEvent(EntityCreated, file.PhotoUID, c)
PublishPhotoEvent(EntityUpdated, photoUID, c)

View file

@ -0,0 +1,28 @@
package api
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
)
func TestPhotoUnstack(t *testing.T) {
t.Run("unstack xmp sidecar file", func(t *testing.T) {
app, router, _ := NewApiTest()
PhotoUnstack(router)
r := PerformRequest(app, "POST", "/api/v1/photos/pt9jtdre2lvl0yh7/files/ft1es39w45bnlqdw/unstack")
// Sidecar files can not be unstacked.
assert.Equal(t, http.StatusBadRequest, r.Code)
// t.Logf("RESP: %s", r.Body.String())
})
t.Run("unstack bridge3.jpg", func(t *testing.T) {
app, router, _ := NewApiTest()
PhotoUnstack(router)
r := PerformRequest(app, "POST", "/api/v1/photos/pt9jtdre2lvl0yh7/files/ft2es49whhbnlqdn/unstack")
// TODO: Have a real file in place for testing the success case. This file does not exist, so it can't be unstacked.
assert.Equal(t, http.StatusNotFound, r.Code)
// t.Logf("RESP: %s", r.Body.String())
})
}

View file

@ -54,7 +54,7 @@ func purgeAction(ctx *cli.Context) error {
if subPath == "" {
log.Infof("removing missing files in %s", txt.Quote(filepath.Base(conf.OriginalsPath())))
} else {
log.Infof("removing missing files in %s", txt.Quote(fs.Rel(filepath.Join(conf.OriginalsPath(), subPath), filepath.Dir(conf.OriginalsPath()))))
log.Infof("removing missing files in %s", txt.Quote(fs.RelName(filepath.Join(conf.OriginalsPath(), subPath), filepath.Dir(conf.OriginalsPath()))))
}
if conf.ReadOnly() {

View file

@ -78,6 +78,15 @@ func FirstFileByHash(fileHash string) (File, error) {
return file, q.Error
}
// PrimaryFile returns the primary file for a photo uid.
func PrimaryFile(photoUID string) (File, error) {
var file File
q := Db().Unscoped().First(&file, "file_primary = 1 AND photo_uid = ?", photoUID)
return file, q.Error
}
// BeforeCreate creates a random UID if needed before inserting a new row to the database.
func (m *File) BeforeCreate(scope *gorm.Scope) error {
if rnd.IsUID(m.FileUID, 'f') {

View file

@ -902,6 +902,7 @@ func (m *Photo) Delete(permanently bool) error {
// Delete permanently deletes the entity from the database.
func (m *Photo) DeletePermanently() error {
Db().Unscoped().Delete(File{}, "photo_id = ?", m.ID)
Db().Unscoped().Delete(Details{}, "photo_id = ?", m.ID)
Db().Unscoped().Delete(PhotoKeyword{}, "photo_id = ?", m.ID)
Db().Unscoped().Delete(PhotoLabel{}, "photo_id = ?", m.ID)
Db().Unscoped().Delete(PhotoAlbum{}, "photo_uid = ?", m.PhotoUID)
@ -980,3 +981,8 @@ func (m *Photo) Approve() error {
func (m *Photo) Links() Links {
return FindLinks("", m.PhotoUID)
}
// PrimaryFile returns the primary file for this photo.
func (m *Photo) PrimaryFile() (File, error) {
return PrimaryFile(m.PhotoUID)
}

View file

@ -59,10 +59,12 @@ func (data *Data) Exif(fileName string) (err error) {
_, rawExif, err = sl.Exif()
if err != nil {
if err.Error() == "no exif header" {
if strings.HasPrefix(err.Error(), "no exif header") {
return fmt.Errorf("metadata: no exif header in %s", logName)
} else if strings.HasPrefix(err.Error(), "no exif data") {
log.Debugf("metadata: failed parsing %s, starting brute-force search (exif)", logName)
} else {
log.Warnf("metadata: %s in %s (parse jpeg)", err, logName)
log.Warnf("metadata: %s in %s, starting brute-force search (exif)", err, logName)
}
} else {
parsed = true
@ -133,7 +135,7 @@ func (data *Data) Exif(fileName string) (err error) {
valueString, err = ite.FormatFirst()
if err != nil {
log.Errorf("metadata: %s in %s (exif)", err, logName)
log.Errorf("metadata: %s in %s (find exif tags)", err, logName)
return nil
}
@ -292,7 +294,8 @@ func (data *Data) Exif(fileName string) (err error) {
data.Lng = float32(gi.Longitude.Decimal())
data.Altitude = gi.Altitude
} else {
log.Warnf("metadata: %s in %s (exif)", err, logName)
log.Debugf("exif: %s in %s", err, logName)
log.Warnf("metadata: failed parsing gps coordinates in %s (exif)", logName)
}
}
@ -332,7 +335,7 @@ func (data *Data) Exif(fileName string) (err error) {
} else if tl, err := time.ParseInLocation("2006:01:02 15:04:05", takenAt, loc); err == nil {
data.TakenAt = tl.Round(time.Second).UTC()
} else {
log.Errorf("metadata: %s in %s (exif)", err.Error(), logName) // this should never happen
log.Errorf("metadata: %s in %s (exif time)", err.Error(), logName) // this should never happen
}
} else {
log.Warnf("metadata: invalid time %s in %s (exif)", takenAt, logName)

View file

@ -266,4 +266,25 @@ func TestExif(t *testing.T) {
assert.EqualError(t, err, "metadata: no exif header in gopher-preview.jpg (search and extract)")
})
t.Run("huawei-gps-error.jpg", func(t *testing.T) {
data, err := Exif("testdata/huawei-gps-error.jpg")
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "2020-06-16T18:52:46Z", data.TakenAt.Format("2006-01-02T15:04:05Z"))
assert.Equal(t, "2020-06-16T18:52:46Z", data.TakenAtLocal.Format("2006-01-02T15:04:05Z"))
assert.Equal(t, float32(0), data.Lat)
assert.Equal(t, float32(0), data.Lng)
assert.Equal(t, 0, data.Altitude)
assert.Equal(t, "1/110", data.Exposure)
assert.Equal(t, "HUAWEI", data.CameraMake)
assert.Equal(t, "ELE-L29", data.CameraModel)
assert.Equal(t, 27, data.FocalLength)
assert.Equal(t, 0, data.Orientation)
assert.Equal(t, "", data.LensMake)
assert.Equal(t, "", data.LensModel)
})
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

View file

@ -60,7 +60,7 @@ func (c *Convert) Start(path string) error {
}
ignore.Log = func(fileName string) {
log.Infof(`convert: ignored "%s"`, fs.Rel(fileName, path))
log.Infof(`convert: ignored "%s"`, fs.RelName(fileName, path))
}
err := godirwalk.Walk(path, &godirwalk.Options{
@ -118,12 +118,12 @@ func (c *Convert) ToJson(mf *MediaFile) (*MediaFile, error) {
}
if !c.conf.SidecarWritable() {
return nil, fmt.Errorf("convert: metadata export to json disabled in read only mode (%s)", mf.RelativeName(c.conf.OriginalsPath()))
return nil, fmt.Errorf("convert: metadata export to json disabled in read only mode (%s)", mf.RelName(c.conf.OriginalsPath()))
}
jsonName = fs.FileName(mf.FileName(), c.conf.SidecarPath(), c.conf.OriginalsPath(), ".json", c.conf.Settings().Index.Sequences)
fileName := mf.RelativeName(c.conf.OriginalsPath())
fileName := mf.RelName(c.conf.OriginalsPath())
log.Infof("convert: %s -> %s", fileName, filepath.Base(jsonName))
@ -184,7 +184,7 @@ func (c *Convert) JpegConvertCommand(mf *MediaFile, jpegName string, xmpName str
result = exec.Command(c.conf.DarktableBin(), args...)
} else {
return nil, useMutex, fmt.Errorf("convert: no raw to jpeg converter installed (%s)", mf.Base(c.conf.Settings().Index.Sequences))
return nil, useMutex, fmt.Errorf("convert: no raw to jpeg converter installed (%s)", mf.BasePrefix(c.conf.Settings().Index.Sequences))
}
} else if mf.IsVideo() {
result = exec.Command(c.conf.FFmpegBin(), "-i", mf.FileName(), "-ss", "00:00:00.001", "-vframes", "1", jpegName)
@ -200,7 +200,7 @@ func (c *Convert) JpegConvertCommand(mf *MediaFile, jpegName string, xmpName str
// ToJpeg converts a single image file to JPEG if possible.
func (c *Convert) ToJpeg(image *MediaFile) (*MediaFile, error) {
if !image.Exists() {
return nil, fmt.Errorf("convert: can not convert to jpeg, file does not exist (%s)", image.RelativeName(c.conf.OriginalsPath()))
return nil, fmt.Errorf("convert: can not convert to jpeg, file does not exist (%s)", image.RelName(c.conf.OriginalsPath()))
}
if image.IsJpeg() {
@ -216,11 +216,11 @@ func (c *Convert) ToJpeg(image *MediaFile) (*MediaFile, error) {
}
if !c.conf.SidecarWritable() {
return nil, fmt.Errorf("convert: disabled in read only mode (%s)", image.RelativeName(c.conf.OriginalsPath()))
return nil, fmt.Errorf("convert: disabled in read only mode (%s)", image.RelName(c.conf.OriginalsPath()))
}
jpegName = fs.FileName(image.FileName(), c.conf.SidecarPath(), c.conf.OriginalsPath(), fs.JpegExt, c.conf.Settings().Index.Sequences)
fileName := image.RelativeName(c.conf.OriginalsPath())
fileName := image.RelName(c.conf.OriginalsPath())
log.Infof("convert: %s -> %s", fileName, filepath.Base(jpegName))

View file

@ -10,7 +10,7 @@ type ConvertJob struct {
func ConvertWorker(jobs <-chan ConvertJob) {
for job := range jobs {
if _, err := job.convert.ToJpeg(job.image); err != nil {
fileName := job.image.RelativeName(job.convert.conf.OriginalsPath())
fileName := job.image.RelName(job.convert.conf.OriginalsPath())
log.Errorf("convert: could not create jpeg for %s (%s)", fileName, strings.TrimSpace(err.Error()))
}
}

View file

@ -92,7 +92,7 @@ func (imp *Import) Start(opt ImportOptions) map[string]bool {
}
ignore.Log = func(fileName string) {
log.Infof(`import: ignored "%s"`, fs.Rel(fileName, importPath))
log.Infof(`import: ignored "%s"`, fs.RelName(fileName, importPath))
}
err := godirwalk.Walk(importPath, &godirwalk.Options{
@ -118,7 +118,7 @@ func (imp *Import) Start(opt ImportOptions) map[string]bool {
if skip, result := fs.SkipWalk(fileName, isDir, isSymlink, done, ignore); skip {
if isDir && result != filepath.SkipDir {
folder := entity.NewFolder(entity.RootImport, fs.Rel(fileName, imp.conf.ImportPath()), nil)
folder := entity.NewFolder(entity.RootImport, fs.RelName(fileName, imp.conf.ImportPath()), nil)
if err := folder.Create(); err == nil {
log.Infof("import: added folder /%s", folder.Path)
@ -183,9 +183,9 @@ func (imp *Import) Start(opt ImportOptions) map[string]bool {
for _, directory := range directories {
if fs.IsEmpty(directory) {
if err := os.Remove(directory); err != nil {
log.Errorf("import: could not delete empty folder %s (%s)", txt.Quote(fs.Rel(directory, importPath)), err)
log.Errorf("import: could not delete empty folder %s (%s)", txt.Quote(fs.RelName(directory, importPath)), err)
} else {
log.Infof("import: deleted empty folder %s", txt.Quote(fs.Rel(directory, importPath)))
log.Infof("import: deleted empty folder %s", txt.Quote(fs.RelName(directory, importPath)))
}
}
}
@ -199,7 +199,7 @@ func (imp *Import) Start(opt ImportOptions) map[string]bool {
}
if err := os.Remove(file); err != nil {
log.Errorf("import: could not remove %s (%s)", txt.Quote(fs.Rel(file, importPath)), err.Error())
log.Errorf("import: could not remove %s (%s)", txt.Quote(fs.RelName(file, importPath)), err.Error())
}
}
}
@ -250,7 +250,7 @@ func (imp *Import) DestinationFilename(mainFile *MediaFile, mediaFile *MediaFile
for fs.FileExists(result) {
if mediaFile.Hash() == fs.Hash(result) {
return result, fmt.Errorf("%s already exists", txt.Quote(fs.Rel(result, imp.originalsPath())))
return result, fmt.Errorf("%s already exists", txt.Quote(fs.RelName(result, imp.originalsPath())))
}
iteration++

View file

@ -29,11 +29,11 @@ func ImportWorker(jobs <-chan ImportJob) {
importPath := job.ImportOpt.Path
if related.Main == nil {
log.Warnf("import: no media file found for %s", txt.Quote(fs.Rel(job.FileName, importPath)))
log.Warnf("import: no media file found for %s", txt.Quote(fs.RelName(job.FileName, importPath)))
continue
}
originalName := related.Main.RelativeName(importPath)
originalName := related.Main.RelName(importPath)
event.Publish("import.file", event.Data{
"fileName": originalName,
@ -41,7 +41,7 @@ func ImportWorker(jobs <-chan ImportJob) {
})
for _, f := range related.Files {
relativeFilename := f.RelativeName(importPath)
relativeFilename := f.RelName(importPath)
if destinationFilename, err := imp.DestinationFilename(related.Main, f); err == nil {
if err := os.MkdirAll(path.Dir(destinationFilename), os.ModePerm); err != nil {
@ -50,18 +50,18 @@ func ImportWorker(jobs <-chan ImportJob) {
if related.Main.HasSameName(f) {
destinationMainFilename = destinationFilename
log.Infof("import: moving main %s file %s to %s", f.FileType(), txt.Quote(relativeFilename), txt.Quote(fs.Rel(destinationFilename, imp.originalsPath())))
log.Infof("import: moving main %s file %s to %s", f.FileType(), txt.Quote(relativeFilename), txt.Quote(fs.RelName(destinationFilename, imp.originalsPath())))
} else {
log.Infof("import: moving related %s file %s to %s", f.FileType(), txt.Quote(relativeFilename), txt.Quote(fs.Rel(destinationFilename, imp.originalsPath())))
log.Infof("import: moving related %s file %s to %s", f.FileType(), txt.Quote(relativeFilename), txt.Quote(fs.RelName(destinationFilename, imp.originalsPath())))
}
if opt.Move {
if err := f.Move(destinationFilename); err != nil {
log.Errorf("import: could not move file to %s (%s)", txt.Quote(fs.Rel(destinationMainFilename, imp.originalsPath())), err.Error())
log.Errorf("import: could not move file to %s (%s)", txt.Quote(fs.RelName(destinationMainFilename, imp.originalsPath())), err.Error())
}
} else {
if err := f.Copy(destinationFilename); err != nil {
log.Errorf("import: could not copy file to %s (%s)", txt.Quote(fs.Rel(destinationMainFilename, imp.originalsPath())), err.Error())
log.Errorf("import: could not copy file to %s (%s)", txt.Quote(fs.RelName(destinationMainFilename, imp.originalsPath())), err.Error())
}
}
} else {
@ -69,7 +69,7 @@ func ImportWorker(jobs <-chan ImportJob) {
if opt.RemoveExistingFiles {
if err := f.Remove(); err != nil {
log.Errorf("import: could not delete %s (%s)", txt.Quote(fs.Rel(f.FileName(), importPath)), err.Error())
log.Errorf("import: could not delete %s (%s)", txt.Quote(fs.RelName(f.FileName(), importPath)), err.Error())
} else {
log.Infof("import: deleted %s (already exists)", txt.Quote(relativeFilename))
}
@ -81,7 +81,7 @@ func ImportWorker(jobs <-chan ImportJob) {
f, err := NewMediaFile(destinationMainFilename)
if err != nil {
log.Errorf("import: could not import %s (%s)", txt.Quote(fs.Rel(destinationMainFilename, imp.originalsPath())), err.Error())
log.Errorf("import: could not import %s (%s)", txt.Quote(fs.RelName(destinationMainFilename, imp.originalsPath())), err.Error())
continue
}
@ -90,7 +90,7 @@ func ImportWorker(jobs <-chan ImportJob) {
log.Errorf("import: creating jpeg failed (%s)", err.Error())
continue
} else {
log.Infof("import: %s created", fs.Rel(jpegFile.FileName(), imp.originalsPath()))
log.Infof("import: %s created", fs.RelName(jpegFile.FileName(), imp.originalsPath()))
}
}
@ -107,14 +107,14 @@ func ImportWorker(jobs <-chan ImportJob) {
if jsonFile, err := imp.convert.ToJson(f); err != nil {
log.Errorf("import: creating json sidecar file failed (%s)", err.Error())
} else {
log.Infof("import: %s created", fs.Rel(jsonFile.FileName(), imp.originalsPath()))
log.Infof("import: %s created", fs.RelName(jsonFile.FileName(), imp.originalsPath()))
}
}
related, err := f.RelatedFiles(imp.conf.Settings().Index.Sequences)
if err != nil {
log.Errorf("import: could not index %s (%s)", txt.Quote(fs.Rel(destinationMainFilename, imp.originalsPath())), err.Error())
log.Errorf("import: could not index %s (%s)", txt.Quote(fs.RelName(destinationMainFilename, imp.originalsPath())), err.Error())
continue
}
@ -131,7 +131,7 @@ func ImportWorker(jobs <-chan ImportJob) {
res := ind.MediaFile(related.Main, indexOpt, originalName)
log.Infof("import: %s main %s file %s", res, related.Main.FileType(), txt.Quote(related.Main.RelativeName(ind.originalsPath())))
log.Infof("import: %s main %s file %s", res, related.Main.FileType(), txt.Quote(related.Main.RelName(ind.originalsPath())))
done[related.Main.FileName()] = true
if res.Success() {
@ -142,7 +142,7 @@ func ImportWorker(jobs <-chan ImportJob) {
continue
}
} else {
log.Warnf("import: no main file for %s (conversion to jpeg failed?)", fs.Rel(destinationMainFilename, imp.originalsPath()))
log.Warnf("import: no main file for %s (conversion to jpeg failed?)", fs.RelName(destinationMainFilename, imp.originalsPath()))
}
for _, f := range related.Files {
@ -157,7 +157,7 @@ func ImportWorker(jobs <-chan ImportJob) {
res := ind.MediaFile(f, indexOpt, "")
done[f.FileName()] = true
log.Infof("import: %s related %s file %s", res, f.FileType(), txt.Quote(f.RelativeName(ind.originalsPath())))
log.Infof("import: %s related %s file %s", res, f.FileType(), txt.Quote(f.RelName(ind.originalsPath())))
}
}

View file

@ -108,7 +108,7 @@ func (ind *Index) Start(opt IndexOptions) map[string]bool {
}
ignore.Log = func(fileName string) {
log.Infof(`index: ignored "%s"`, fs.Rel(fileName, originalsPath))
log.Infof(`index: ignored "%s"`, fs.RelName(fileName, originalsPath))
}
err := godirwalk.Walk(optionsPath, &godirwalk.Options{
@ -122,7 +122,7 @@ func (ind *Index) Start(opt IndexOptions) map[string]bool {
if skip, result := fs.SkipWalk(fileName, isDir, isSymlink, done, ignore); skip {
if isDir && result != filepath.SkipDir {
folder := entity.NewFolder(entity.RootOriginals, fs.Rel(fileName, originalsPath), nil)
folder := entity.NewFolder(entity.RootOriginals, fs.RelName(fileName, originalsPath), nil)
if err := folder.Create(); err == nil {
log.Infof("index: added folder /%s", folder.Path)
@ -191,3 +191,26 @@ func (ind *Index) Start(opt IndexOptions) map[string]bool {
return done
}
// File indexes a single file and returns the result.
func (ind *Index) File(name string) (result IndexResult) {
file, err := NewMediaFile(name)
if err != nil {
result.Err = err
result.Status = IndexFailed
return result
}
related, err := file.RelatedFiles(false)
if err != nil {
result.Err = err
result.Status = IndexFailed
return result
}
return IndexRelated(related, ind, IndexOptionsAll())
}

View file

@ -33,7 +33,7 @@ type IndexStatus string
type IndexResult struct {
Status IndexStatus
Error error
Err error
FileID uint
FileUID string
PhotoID uint
@ -44,15 +44,19 @@ func (r IndexResult) String() string {
return string(r.Status)
}
func (r IndexResult) Failed() bool {
return r.Err != nil
}
func (r IndexResult) Success() bool {
return r.Error == nil && r.FileID > 0
return r.Err == nil && r.FileID > 0
}
func (r IndexResult) Indexed() bool {
return r.Status == IndexAdded || r.Status == IndexUpdated || r.Status == IndexStacked
}
func (r IndexResult) Grouped() bool {
func (r IndexResult) Stacked() bool {
return r.Status == IndexStacked
}
@ -60,7 +64,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
if m == nil {
err := errors.New("index: media file is nil - you might have found a bug")
log.Error(err)
result.Error = err
result.Err = err
result.Status = IndexFailed
return result
}
@ -162,10 +166,10 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
if err := photo.LoadFromYaml(yamlName); err != nil {
log.Errorf("index: %s (restore from yaml) for %s", err.Error(), logName)
} else if err := photo.Find(); err != nil {
log.Infof("index: data restored from %s", txt.Quote(fs.Rel(yamlName, Config().OriginalsPath())))
log.Infof("index: data restored from %s", txt.Quote(fs.RelName(yamlName, Config().OriginalsPath())))
} else {
photoExists = true
log.Infof("index: uid %s restored from %s", photo.PhotoUID, txt.Quote(fs.Rel(yamlName, Config().OriginalsPath())))
log.Infof("index: uid %s restored from %s", photo.PhotoUID, txt.Quote(fs.RelName(yamlName, Config().OriginalsPath())))
}
}
}
@ -191,7 +195,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
file.OriginalName = originalName
if file.FilePrimary && photo.OriginalName == "" {
photo.OriginalName = fs.Base(originalName, stripSequence)
photo.OriginalName = fs.BasePrefix(originalName, stripSequence)
}
}
@ -505,14 +509,14 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
if err := photo.Save(); err != nil {
log.Errorf("index: %s for %s", err.Error(), logName)
result.Status = IndexFailed
result.Error = err
result.Err = err
return result
}
} else {
if err := photo.FirstOrCreate(); err != nil {
log.Errorf("index: %s", err)
result.Status = IndexFailed
result.Error = err
result.Err = err
return result
}
@ -576,7 +580,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
if err := photo.Save(); err != nil {
log.Errorf("index: %s for %s", err, logName)
result.Status = IndexFailed
result.Error = err
result.Err = err
return result
}
@ -595,7 +599,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
if err := photo.Save(); err != nil {
log.Errorf("index: %s for %s", err, logName)
result.Status = IndexFailed
result.Error = err
result.Err = err
return result
}
}
@ -608,7 +612,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
if err := file.Save(); err != nil {
log.Errorf("index: %s for %s", err, logName)
result.Status = IndexFailed
result.Error = err
result.Err = err
return result
}
} else {
@ -617,7 +621,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
if err := file.Create(); err != nil {
log.Errorf("index: %s for %s", err, logName)
result.Status = IndexFailed
result.Error = err
result.Err = err
return result
}
@ -658,7 +662,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName string) (
if err := photo.SaveAsYaml(yamlFile); err != nil {
log.Errorf("index: %s (update yaml) for %s", err.Error(), logName)
} else {
log.Infof("index: updated yaml file %s", txt.Quote(fs.Rel(yamlFile, Config().OriginalsPath())))
log.Infof("index: updated yaml file %s", txt.Quote(fs.RelName(yamlFile, Config().OriginalsPath())))
}
}
@ -679,7 +683,7 @@ func (ind *Index) NSFW(jpeg *MediaFile) bool {
return false
} else {
if nsfwLabels.NSFW(nsfw.ThresholdHigh) {
log.Warnf("index: %s might contain offensive content", txt.Quote(jpeg.RelativeName(Config().OriginalsPath())))
log.Warnf("index: %s might contain offensive content", txt.Quote(jpeg.RelName(Config().OriginalsPath())))
return true
}
}

View file

@ -1,8 +1,9 @@
package photoprism
import (
"github.com/stretchr/testify/assert"
"testing"
"github.com/stretchr/testify/assert"
)
func TestIndexOptionsNone(t *testing.T) {

View file

@ -0,0 +1,107 @@
package photoprism
import (
"fmt"
"path/filepath"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt"
)
// IndexMain indexes the main file from a group of related files and returns the result.
func IndexMain(related *RelatedFiles, ind *Index, opt IndexOptions) (result IndexResult) {
// Skip sidecar files without related media file.
if related.Main == nil {
result.Err = fmt.Errorf("index: no main file found for %s", txt.Quote(related.String()))
result.Status = IndexFailed
return result
}
// Enforce file size limit for originals.
if ind.conf.OriginalsLimit() > 0 && related.Main.FileSize() > ind.conf.OriginalsLimit() {
result.Err = fmt.Errorf("index: %s exceeds file size limit for originals [%d / %d MB]", filepath.Base(related.Main.FileName()), related.Main.FileSize()/(1024*1024), ind.conf.OriginalsLimit()/(1024*1024))
result.Status = IndexFailed
return result
}
f := related.Main
if opt.Convert && !f.HasJpeg() {
if jpegFile, err := ind.convert.ToJpeg(f); err != nil {
result.Err = fmt.Errorf("index: creating jpeg failed (%s)", err.Error())
result.Status = IndexFailed
return result
} else {
log.Infof("index: %s created", fs.RelName(jpegFile.FileName(), ind.originalsPath()))
if err := jpegFile.ResampleDefault(ind.thumbPath(), false); err != nil {
result.Err = fmt.Errorf("index: could not create default thumbnails (%s)", err.Error())
result.Status = IndexFailed
return result
}
related.Files = append(related.Files, jpegFile)
}
}
if ind.conf.SidecarJson() && !f.HasJson() {
if jsonFile, err := ind.convert.ToJson(f); err != nil {
log.Errorf("index: creating json sidecar file failed (%s)", err.Error())
} else {
log.Infof("index: %s created", fs.RelName(jsonFile.FileName(), ind.originalsPath()))
}
}
result = ind.MediaFile(f, opt, "")
if result.Indexed() && f.IsJpeg() {
if err := f.ResampleDefault(ind.thumbPath(), false); err != nil {
log.Errorf("index: could not create default thumbnails (%s)", err.Error())
query.SetFileError(result.FileUID, err.Error())
}
}
log.Infof("index: %s main %s file %s", result, f.FileType(), txt.Quote(f.RelName(ind.originalsPath())))
return result
}
// IndexMain indexes a group of related files and returns the result.
func IndexRelated(related RelatedFiles, ind *Index, opt IndexOptions) (result IndexResult) {
done := make(map[string]bool)
result = IndexMain(&related, ind, opt)
if result.Failed() {
log.Error(result.Err)
return result
} else if !result.Success() || result.Stacked() {
// Skip related files if main file was stacked or indexing was not completely successful.
return result
}
done[related.Main.FileName()] = true
for _, f := range related.Files {
if done[f.FileName()] {
continue
}
res := ind.MediaFile(f, opt, "")
done[f.FileName()] = true
if res.Indexed() && f.IsJpeg() {
if err := f.ResampleDefault(ind.thumbPath(), false); err != nil {
log.Errorf("index: could not create default thumbnails (%s)", err.Error())
query.SetFileError(res.FileUID, err.Error())
}
}
log.Infof("index: %s related %s file %s", res, f.FileType(), txt.Quote(f.RelName(ind.originalsPath())))
}
return result
}

View file

@ -0,0 +1,73 @@
package photoprism
import (
"path/filepath"
"testing"
"github.com/photoprism/photoprism/internal/classify"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/nsfw"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/stretchr/testify/assert"
)
func TestIndexRelated(t *testing.T) {
conf := config.TestConfig()
testFile, err := NewMediaFile("testdata/2018-04-12 19:24:49.gif")
if err != nil {
t.Fatal(err)
}
testRelated, err := testFile.RelatedFiles(true)
if err != nil {
t.Fatal(err)
}
testToken := rnd.Token(8)
testPath := filepath.Join(conf.OriginalsPath(), testToken)
for _, f := range testRelated.Files {
dest := filepath.Join(testPath, f.BaseName())
if err := f.Copy(dest); err != nil {
t.Fatalf("COPY FAILED: %s", err)
}
}
mainFile, err := NewMediaFile(filepath.Join(testPath, "2018-04-12 19:24:49.gif"))
if err != nil {
t.Fatal(err)
}
related, err := mainFile.RelatedFiles(true)
if err != nil {
t.Fatal(err)
}
tf := classify.New(conf.AssetsPath(), conf.TensorFlowOff())
nd := nsfw.New(conf.NSFWModelPath())
convert := NewConvert(conf)
ind := NewIndex(conf, tf, nd, convert)
opt := IndexOptionsAll()
result := IndexRelated(related, ind, opt)
assert.False(t, result.Failed())
assert.False(t, result.Stacked())
assert.True(t, result.Success())
assert.Equal(t, IndexAdded, result.Status)
if photo, err := query.PhotoByUID(result.PhotoUID); err != nil {
t.Fatal(err)
} else {
assert.Equal(t, "2018-04-12 19:24:49 +0000 UTC", photo.TakenAt.String())
assert.Equal(t, "name", photo.TakenSrc)
}
}

View file

@ -1,13 +1,5 @@
package photoprism
import (
"path/filepath"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt"
)
type IndexJob struct {
FileName string
Related RelatedFiles
@ -17,82 +9,6 @@ type IndexJob struct {
func IndexWorker(jobs <-chan IndexJob) {
for job := range jobs {
done := make(map[string]bool)
related := job.Related
opt := job.IndexOpt
ind := job.Ind
// Skip sidecar files without related media file.
if related.Main == nil {
log.Warnf("index: no media file found for %s", txt.Quote(fs.Rel(job.FileName, ind.originalsPath())))
continue
}
// Enforce file size limit for originals.
if ind.conf.OriginalsLimit() > 0 && related.Main.FileSize() > ind.conf.OriginalsLimit() {
log.Warnf("index: %s exceeds file size limit for originals [%d / %d MB]", filepath.Base(related.Main.FileName()), related.Main.FileSize()/(1024*1024), ind.conf.OriginalsLimit()/(1024*1024))
continue
}
f := related.Main
if opt.Convert && !f.HasJpeg() {
if jpegFile, err := ind.convert.ToJpeg(f); err != nil {
log.Errorf("index: creating jpeg failed (%s)", err.Error())
continue
} else {
log.Infof("index: %s created", fs.Rel(jpegFile.FileName(), ind.originalsPath()))
if err := jpegFile.ResampleDefault(ind.thumbPath(), false); err != nil {
log.Errorf("index: could not create default thumbnails (%s)", err.Error())
continue
}
related.Files = append(related.Files, jpegFile)
}
}
if ind.conf.SidecarJson() && !f.HasJson() {
if jsonFile, err := ind.convert.ToJson(f); err != nil {
log.Errorf("index: creating json sidecar file failed (%s)", err.Error())
} else {
log.Infof("index: %s created", fs.Rel(jsonFile.FileName(), ind.originalsPath()))
}
}
res := ind.MediaFile(f, opt, "")
done[f.FileName()] = true
if res.Indexed() && f.IsJpeg() {
if err := f.ResampleDefault(ind.thumbPath(), false); err != nil {
log.Errorf("index: could not create default thumbnails (%s)", err.Error())
query.SetFileError(res.FileUID, err.Error())
}
}
log.Infof("index: %s main %s file %s", res, f.FileType(), txt.Quote(f.RelativeName(ind.originalsPath())))
// Skip related files if main file was merged or an error occurred.
if !res.Success() || res.Grouped() {
continue
}
for _, f := range related.Files {
if done[f.FileName()] {
continue
}
res := ind.MediaFile(f, opt, "")
done[f.FileName()] = true
if res.Indexed() && f.IsJpeg() {
if err := f.ResampleDefault(ind.thumbPath(), false); err != nil {
log.Errorf("index: could not create default thumbnails (%s)", err.Error())
query.SetFileError(res.FileUID, err.Error())
}
}
log.Infof("index: %s related %s file %s", res, f.FileType(), txt.Quote(f.RelativeName(ind.originalsPath())))
}
IndexRelated(job.Related, job.Ind, job.IndexOpt)
}
}

View file

@ -218,7 +218,7 @@ func (m *MediaFile) CanonicalNameFromFile() string {
// CanonicalNameFromFileWithDirectory gets the canonical name for a MediaFile
// including the directory.
func (m *MediaFile) CanonicalNameFromFileWithDirectory() string {
return m.Directory() + string(os.PathSeparator) + m.CanonicalNameFromFile()
return m.Dir() + string(os.PathSeparator) + m.CanonicalNameFromFile()
}
// Hash returns the SHA1 hash of a media file.
@ -266,7 +266,7 @@ func (m *MediaFile) JsonName() string {
// RelatedFiles returns files which are related to this file.
func (m *MediaFile) RelatedFiles(stripSequence bool) (result RelatedFiles, err error) {
// escape any meta characters in the file name
matches, err := filepath.Glob(regexp.QuoteMeta(m.AbsBase(stripSequence)) + "*")
matches, err := filepath.Glob(regexp.QuoteMeta(m.AbsPrefix(stripSequence)) + "*")
if err != nil {
return result, err
@ -332,9 +332,9 @@ func (m *MediaFile) PathNameInfo() (fileRoot, fileBase, relativePath, relativeNa
rootPath = Config().OriginalsPath()
}
fileBase = m.Base(Config().Settings().Index.Sequences)
relativePath = m.RelativePath(rootPath)
relativeName = m.RelativeName(rootPath)
fileBase = m.BasePrefix(Config().Settings().Index.Sequences)
relativePath = m.RelPath(rootPath)
relativeName = m.RelName(rootPath)
return fileRoot, fileBase, relativePath, relativeName
}
@ -354,13 +354,13 @@ func (m *MediaFile) SetFileName(fileName string) {
m.fileName = fileName
}
// Rel returns the relative filename.
func (m *MediaFile) RelativeName(directory string) string {
return fs.Rel(m.fileName, directory)
// RelName returns the relative filename.
func (m *MediaFile) RelName(directory string) string {
return fs.RelName(m.fileName, directory)
}
// RelativePath returns the relative path without filename.
func (m *MediaFile) RelativePath(directory string) string {
// RelPath returns the relative path without filename.
func (m *MediaFile) RelPath(directory string) string {
pathname := m.fileName
if i := strings.Index(pathname, directory); i == 0 {
@ -390,31 +390,31 @@ func (m *MediaFile) RelativePath(directory string) string {
return pathname
}
// RelBase returns the relative filename.
func (m *MediaFile) RelativeBase(directory string, stripSequence bool) string {
if relativePath := m.RelativePath(directory); relativePath != "" {
return filepath.Join(relativePath, m.Base(stripSequence))
// RelPrefix returns the relative path and file name prefix.
func (m *MediaFile) RelPrefix(directory string, stripSequence bool) string {
if relativePath := m.RelPath(directory); relativePath != "" {
return filepath.Join(relativePath, m.BasePrefix(stripSequence))
}
return m.Base(stripSequence)
return m.BasePrefix(stripSequence)
}
// Directory returns the file path.
func (m *MediaFile) Directory() string {
// Dir returns the file path.
func (m *MediaFile) Dir() string {
return filepath.Dir(m.fileName)
}
// SubDirectory returns a sub directory name.
func (m *MediaFile) SubDirectory(dir string) string {
// SubDir returns a sub directory name.
func (m *MediaFile) SubDir(dir string) string {
return filepath.Join(filepath.Dir(m.fileName), dir)
}
// Base returns the filename base without any extensions and path.
func (m *MediaFile) Base(stripSequence bool) string {
return fs.Base(m.FileName(), stripSequence)
// BasePrefix returns the filename base without any extensions and path.
func (m *MediaFile) BasePrefix(stripSequence bool) string {
return fs.BasePrefix(m.FileName(), stripSequence)
}
// Base returns the filename base without any extensions and path.
// Root returns the file root directory.
func (m *MediaFile) Root() string {
if strings.HasPrefix(m.FileName(), Config().OriginalsPath()) {
return entity.RootOriginals
@ -441,9 +441,9 @@ func (m *MediaFile) Root() string {
return ""
}
// AbsBase returns the directory and base filename without any extensions.
func (m *MediaFile) AbsBase(stripSequence bool) string {
return fs.AbsBase(m.FileName(), stripSequence)
// AbsPrefix returns the directory and base filename without any extensions.
func (m *MediaFile) AbsPrefix(stripSequence bool) string {
return fs.AbsPrefix(m.FileName(), stripSequence)
}
// MimeType returns the mime type.
@ -487,16 +487,20 @@ func (m *MediaFile) HasSameName(f *MediaFile) bool {
}
// Move file to a new destination with the filename provided in parameter.
func (m *MediaFile) Move(newFilename string) error {
if err := os.Rename(m.fileName, newFilename); err != nil {
func (m *MediaFile) Move(dest string) error {
if err := os.MkdirAll(filepath.Dir(dest), os.ModePerm); err != nil {
return err
}
if err := os.Rename(m.fileName, dest); err != nil {
log.Debugf("could not rename file, falling back to copy and delete: %s", err.Error())
} else {
m.fileName = newFilename
m.fileName = dest
return nil
}
if err := m.Copy(newFilename); err != nil {
if err := m.Copy(dest); err != nil {
return err
}
@ -504,32 +508,36 @@ func (m *MediaFile) Move(newFilename string) error {
return err
}
m.fileName = newFilename
m.fileName = dest
return nil
}
// Copy a MediaFile to another file by destinationFilename.
func (m *MediaFile) Copy(destinationFilename string) error {
file, err := m.openFile()
func (m *MediaFile) Copy(dest string) error {
if err := os.MkdirAll(filepath.Dir(dest), os.ModePerm); err != nil {
return err
}
thisFile, err := m.openFile()
if err != nil {
log.Error(err.Error())
return err
}
defer file.Close()
defer thisFile.Close()
destination, err := os.OpenFile(destinationFilename, os.O_RDWR|os.O_CREATE, 0666)
destFile, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE, os.ModePerm)
if err != nil {
log.Error(err.Error())
return err
}
defer destination.Close()
defer destFile.Close()
_, err = io.Copy(destination, file)
_, err = io.Copy(destFile, thisFile)
if err != nil {
log.Error(err.Error())
@ -819,11 +827,11 @@ func (m *MediaFile) ResampleDefault(thumbPath string, force bool) (err error) {
defer func() {
switch count {
case 0:
log.Debug(capture.Time(start, fmt.Sprintf("mediafile: no new thumbnails created for %s", m.Base(false))))
log.Debug(capture.Time(start, fmt.Sprintf("mediafile: no new thumbnails created for %s", m.BasePrefix(false))))
case 1:
log.Info(capture.Time(start, fmt.Sprintf("mediafile: one thumbnail created for %s", m.Base(false))))
log.Info(capture.Time(start, fmt.Sprintf("mediafile: one thumbnail created for %s", m.BasePrefix(false))))
default:
log.Info(capture.Time(start, fmt.Sprintf("mediafile: %d thumbnails created for %s", count, m.Base(false))))
log.Info(capture.Time(start, fmt.Sprintf("mediafile: %d thumbnails created for %s", count, m.BasePrefix(false))))
}
}()

View file

@ -432,7 +432,7 @@ func TestMediaFile_RelatedFiles(t *testing.T) {
t.Fatalf("extension should be longer: %s", extension)
}
relativePath := result.RelativePath(conf.ExamplesPath())
relativePath := result.RelPath(conf.ExamplesPath())
if len(relativePath) > 0 {
t.Fatalf("relative path should be empty: %s", relativePath)
@ -634,20 +634,20 @@ func TestMediaFile_RelativeFilename(t *testing.T) {
}
t.Run("directory with end slash", func(t *testing.T) {
filename := mediaFile.RelativeName("/go/src/github.com/photoprism/photoprism/assets/")
filename := mediaFile.RelName("/go/src/github.com/photoprism/photoprism/assets/")
assert.Equal(t, "examples/tree_white.jpg", filename)
})
t.Run("directory without end slash", func(t *testing.T) {
filename := mediaFile.RelativeName("/go/src/github.com/photoprism/photoprism/assets")
filename := mediaFile.RelName("/go/src/github.com/photoprism/photoprism/assets")
assert.Equal(t, "examples/tree_white.jpg", filename)
})
t.Run("directory not part of filename", func(t *testing.T) {
filename := mediaFile.RelativeName("xxx/")
filename := mediaFile.RelName("xxx/")
assert.Equal(t, conf.ExamplesPath()+"/tree_white.jpg", filename)
})
t.Run("directory equals example path", func(t *testing.T) {
filename := mediaFile.RelativeName("/go/src/github.com/photoprism/photoprism/assets/examples")
filename := mediaFile.RelName("/go/src/github.com/photoprism/photoprism/assets/examples")
assert.Equal(t, "tree_white.jpg", filename)
})
}
@ -663,19 +663,19 @@ func TestMediaFile_RelativePath(t *testing.T) {
}
t.Run("directory with end slash", func(t *testing.T) {
path := mediaFile.RelativePath("/go/src/github.com/photoprism/photoprism/assets/")
path := mediaFile.RelPath("/go/src/github.com/photoprism/photoprism/assets/")
assert.Equal(t, "examples", path)
})
t.Run("directory without end slash", func(t *testing.T) {
path := mediaFile.RelativePath("/go/src/github.com/photoprism/photoprism/assets")
path := mediaFile.RelPath("/go/src/github.com/photoprism/photoprism/assets")
assert.Equal(t, "examples", path)
})
t.Run("directory equals filepath", func(t *testing.T) {
path := mediaFile.RelativePath(conf.ExamplesPath())
path := mediaFile.RelPath(conf.ExamplesPath())
assert.Equal(t, "", path)
})
t.Run("directory does not match filepath", func(t *testing.T) {
path := mediaFile.RelativePath("xxx")
path := mediaFile.RelPath("xxx")
assert.Equal(t, "/go/src/github.com/photoprism/photoprism/assets/examples", path)
})
@ -686,15 +686,15 @@ func TestMediaFile_RelativePath(t *testing.T) {
}
t.Run("hidden", func(t *testing.T) {
path := mediaFile.RelativePath(conf.ExamplesPath())
path := mediaFile.RelPath(conf.ExamplesPath())
assert.Equal(t, "", path)
})
t.Run("hidden empty", func(t *testing.T) {
path := mediaFile.RelativePath("")
path := mediaFile.RelPath("")
assert.Equal(t, conf.ExamplesPath(), path)
})
t.Run("hidden root", func(t *testing.T) {
path := mediaFile.RelativePath(filepath.Join(conf.ExamplesPath(), fs.HiddenPath))
path := mediaFile.RelPath(filepath.Join(conf.ExamplesPath(), fs.HiddenPath))
assert.Equal(t, "", path)
})
}
@ -708,15 +708,15 @@ func TestMediaFile_RelativeBasename(t *testing.T) {
}
t.Run("directory with end slash", func(t *testing.T) {
basename := mediaFile.RelativeBase("/go/src/github.com/photoprism/photoprism/assets/", true)
basename := mediaFile.RelPrefix("/go/src/github.com/photoprism/photoprism/assets/", true)
assert.Equal(t, "examples/tree_white", basename)
})
t.Run("directory without end slash", func(t *testing.T) {
basename := mediaFile.RelativeBase("/go/src/github.com/photoprism/photoprism/assets", true)
basename := mediaFile.RelPrefix("/go/src/github.com/photoprism/photoprism/assets", true)
assert.Equal(t, "examples/tree_white", basename)
})
t.Run("directory equals example path", func(t *testing.T) {
basename := mediaFile.RelativeBase("/go/src/github.com/photoprism/photoprism/assets/examples/", true)
basename := mediaFile.RelPrefix("/go/src/github.com/photoprism/photoprism/assets/examples/", true)
assert.Equal(t, "tree_white", basename)
})
@ -730,7 +730,7 @@ func TestMediaFile_Directory(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, conf.ExamplesPath(), mediaFile.Directory())
assert.Equal(t, conf.ExamplesPath(), mediaFile.Dir())
})
}
@ -742,7 +742,7 @@ func TestMediaFile_Basename(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "limes", mediaFile.Base(true))
assert.Equal(t, "limes", mediaFile.BasePrefix(true))
})
t.Run("/IMG_4120 copy.JPG", func(t *testing.T) {
conf := config.TestConfig()
@ -751,7 +751,7 @@ func TestMediaFile_Basename(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "IMG_4120", mediaFile.Base(true))
assert.Equal(t, "IMG_4120", mediaFile.BasePrefix(true))
})
t.Run("/IMG_4120 (1).JPG", func(t *testing.T) {
conf := config.TestConfig()
@ -760,7 +760,7 @@ func TestMediaFile_Basename(t *testing.T) {
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "IMG_4120", mediaFile.Base(true))
assert.Equal(t, "IMG_4120", mediaFile.BasePrefix(true))
})
}
@ -1834,7 +1834,7 @@ func TestMediaFile_SubDirectory(t *testing.T) {
t.Fatal(err)
}
subdir := mediaFile.SubDirectory("xxx")
subdir := mediaFile.SubDir("xxx")
assert.Equal(t, "/go/src/github.com/photoprism/photoprism/assets/examples/xxx", subdir)
})
}

View file

@ -1,5 +1,9 @@
package photoprism
import (
"strings"
)
// List of related files for importing and indexing.
type RelatedFiles struct {
Files MediaFiles
@ -7,16 +11,32 @@ type RelatedFiles struct {
}
// ContainsJpeg returns true if related file list contains a JPEG.
func (rf RelatedFiles) ContainsJpeg() bool {
for _, f := range rf.Files {
func (m RelatedFiles) ContainsJpeg() bool {
for _, f := range m.Files {
if f.IsJpeg() {
return true
}
}
if rf.Main == nil {
if m.Main == nil {
return false
}
return rf.Main.IsJpeg()
return m.Main.IsJpeg()
}
// String returns file names as string.
func (m RelatedFiles) String() string {
names := make([]string, len(m.Files))
for i, f := range m.Files {
names[i] = f.BaseName()
}
return strings.Join(names, ", ")
}
// Len returns the number of related files.
func (m RelatedFiles) Len() int {
return len(m.Files)
}

View file

@ -63,7 +63,7 @@ func (rs *Resample) Start(force bool) (err error) {
}
ignore.Log = func(fileName string) {
log.Infof(`resample: ignored "%s"`, fs.Rel(fileName, originalsPath))
log.Infof(`resample: ignored "%s"`, fs.RelName(fileName, originalsPath))
}
err = godirwalk.Walk(originalsPath, &godirwalk.Options{
@ -93,7 +93,7 @@ func (rs *Resample) Start(force bool) (err error) {
done[fileName] = true
relativeName := mf.RelativeName(originalsPath)
relativeName := mf.RelName(originalsPath)
event.Publish("index.thumbnails", event.Data{
"fileName": relativeName,

View file

@ -53,8 +53,8 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.UpdatePhotoLabel(v1)
api.GetMomentsTime(v1)
api.GetFile(v1)
api.PhotoFilePrimary(v1)
api.PhotoFileUnstack(v1)
api.PhotoPrimary(v1)
api.PhotoUnstack(v1)
api.GetLabels(v1)
api.UpdateLabel(v1)

View file

@ -35,7 +35,7 @@ func (worker *Sync) relatedDownloads(a entity.Account) (result Downloads, err er
// Group results by directory and base name
for i, file := range files {
k := fs.AbsBase(file.RemoteName, worker.conf.Settings().Index.Sequences)
k := fs.AbsPrefix(file.RemoteName, worker.conf.Settings().Index.Sequences)
result[k] = append(result[k], file)

View file

@ -24,14 +24,8 @@ func StripKnownExt(name string) string {
return name
}
// Base returns the filename base without any extensions and path.
func Base(fileName string, stripSequence bool) string {
name := StripKnownExt(StripExt(filepath.Base(fileName)))
if !stripSequence {
return name
}
// StripSequence removes common sequence patterns at the end of file names.
func StripSequence(name string) string {
// Strip numeric extensions like .00000, .00001, .4542353245,.... (at least 5 digits).
if dot := strings.LastIndex(name, "."); dot != -1 && len(name[dot+1:]) >= 5 {
if i, err := strconv.Atoi(name[dot+1:]); err == nil && i >= 0 {
@ -53,16 +47,27 @@ func Base(fileName string, stripSequence bool) string {
return name
}
// RelBase returns the relative filename.
func RelBase(fileName, dir string, stripSequence bool) string {
if name := Rel(fileName, dir); name != "" {
return AbsBase(name, stripSequence)
// BasePrefix returns the filename base without any extensions and path.
func BasePrefix(fileName string, stripSequence bool) string {
name := StripKnownExt(StripExt(filepath.Base(fileName)))
if !stripSequence {
return name
}
return Base(fileName, stripSequence)
return StripSequence(name)
}
// AbsBase returns the directory and base filename without any extensions.
func AbsBase(fileName string, stripSequence bool) string {
return filepath.Join(filepath.Dir(fileName), Base(fileName, stripSequence))
// RelPrefix returns the relative filename.
func RelPrefix(fileName, dir string, stripSequence bool) string {
if name := RelName(fileName, dir); name != "" {
return AbsPrefix(name, stripSequence)
}
return BasePrefix(fileName, stripSequence)
}
// AbsPrefix returns the directory and base filename without any extensions.
func AbsPrefix(fileName string, stripSequence bool) string {
return filepath.Join(filepath.Dir(fileName), BasePrefix(fileName, stripSequence))
}

View file

@ -42,134 +42,134 @@ func TestStripKnownExt(t *testing.T) {
func TestBase(t *testing.T) {
t.Run("Screenshot 2019-05-21 at 10.45.52.png", func(t *testing.T) {
regular := Base("Screenshot 2019-05-21 at 10.45.52.png", false)
regular := BasePrefix("Screenshot 2019-05-21 at 10.45.52.png", false)
assert.Equal(t, "Screenshot 2019-05-21 at 10.45.52", regular)
stripped := Base("Screenshot 2019-05-21 at 10.45.52.png", true)
stripped := BasePrefix("Screenshot 2019-05-21 at 10.45.52.png", true)
assert.Equal(t, "Screenshot 2019-05-21 at 10.45.52", stripped)
})
t.Run("Test.jpg", func(t *testing.T) {
result := Base("/testdata/Test.jpg", true)
result := BasePrefix("/testdata/Test.jpg", true)
assert.Equal(t, "Test", result)
})
t.Run("Test.jpg.json", func(t *testing.T) {
result := Base("/testdata/Test.jpg.json", true)
result := BasePrefix("/testdata/Test.jpg.json", true)
assert.Equal(t, "Test", result)
})
t.Run("Test copy 3.jpg", func(t *testing.T) {
result := Base("/testdata/Test copy 3.jpg", true)
result := BasePrefix("/testdata/Test copy 3.jpg", true)
assert.Equal(t, "Test", result)
})
t.Run("Test (3).jpg", func(t *testing.T) {
result := Base("/testdata/Test (3).jpg", true)
result := BasePrefix("/testdata/Test (3).jpg", true)
assert.Equal(t, "Test", result)
})
t.Run("Test.jpg", func(t *testing.T) {
result := Base("/testdata/Test.jpg", false)
result := BasePrefix("/testdata/Test.jpg", false)
assert.Equal(t, "Test", result)
})
t.Run("Test.3453453.jpg", func(t *testing.T) {
regular := Base("/testdata/Test.3453453.jpg", false)
regular := BasePrefix("/testdata/Test.3453453.jpg", false)
assert.Equal(t, "Test.3453453", regular)
stripped := Base("/testdata/Test.3453453.jpg", true)
stripped := BasePrefix("/testdata/Test.3453453.jpg", true)
assert.Equal(t, "Test", stripped)
})
t.Run("/foo/bar.0000.ZIP", func(t *testing.T) {
regular := Base("/foo/bar.0000.ZIP", false)
regular := BasePrefix("/foo/bar.0000.ZIP", false)
assert.Equal(t, "bar.0000", regular)
stripped := Base("/foo/bar.0000.ZIP", true)
stripped := BasePrefix("/foo/bar.0000.ZIP", true)
assert.Equal(t, "bar.0000", stripped)
})
t.Run("/foo/bar.00001.ZIP", func(t *testing.T) {
regular := Base("/foo/bar.00001.ZIP", false)
regular := BasePrefix("/foo/bar.00001.ZIP", false)
assert.Equal(t, "bar.00001", regular)
stripped := Base("/foo/bar.00001.ZIP", true)
stripped := BasePrefix("/foo/bar.00001.ZIP", true)
assert.Equal(t, "bar", stripped)
})
t.Run("Test copy 3.jpg", func(t *testing.T) {
result := Base("/testdata/Test copy 3.jpg", false)
result := BasePrefix("/testdata/Test copy 3.jpg", false)
assert.Equal(t, "Test copy 3", result)
})
t.Run("Test (3).jpg", func(t *testing.T) {
result := Base("/testdata/Test (3).jpg", false)
result := BasePrefix("/testdata/Test (3).jpg", false)
assert.Equal(t, "Test (3)", result)
})
t.Run("20180506_091537_DSC02122.JPG", func(t *testing.T) {
result := Base("20180506_091537_DSC02122.JPG", true)
result := BasePrefix("20180506_091537_DSC02122.JPG", true)
assert.Equal(t, "20180506_091537_DSC02122", result)
})
t.Run("20180506_091537_DSC02122 (+3.3).JPG", func(t *testing.T) {
result := Base("20180506_091537_DSC02122 (+3.3).JPG", true)
result := BasePrefix("20180506_091537_DSC02122 (+3.3).JPG", true)
assert.Equal(t, "20180506_091537_DSC02122", result)
})
t.Run("20180506_091537_DSC02122 (-2.7).JPG", func(t *testing.T) {
result := Base("20180506_091537_DSC02122 (-2.7).JPG", true)
result := BasePrefix("20180506_091537_DSC02122 (-2.7).JPG", true)
assert.Equal(t, "20180506_091537_DSC02122", result)
})
t.Run("20180506_091537_DSC02122(+3.3).JPG", func(t *testing.T) {
result := Base("20180506_091537_DSC02122(+3.3).JPG", true)
result := BasePrefix("20180506_091537_DSC02122(+3.3).JPG", true)
assert.Equal(t, "20180506_091537_DSC02122", result)
})
t.Run("20180506_091537_DSC02122(-2.7).JPG", func(t *testing.T) {
result := Base("20180506_091537_DSC02122(-2.7).JPG", true)
result := BasePrefix("20180506_091537_DSC02122(-2.7).JPG", true)
assert.Equal(t, "20180506_091537_DSC02122", result)
})
}
func TestRelBase(t *testing.T) {
t.Run("/foo/bar.0000.ZIP", func(t *testing.T) {
regular := RelBase("/foo/bar.0000.ZIP", "/bar", false)
regular := RelPrefix("/foo/bar.0000.ZIP", "/bar", false)
assert.Equal(t, "/foo/bar.0000", regular)
stripped := RelBase("/foo/bar.0000.ZIP", "/bar", true)
stripped := RelPrefix("/foo/bar.0000.ZIP", "/bar", true)
assert.Equal(t, "/foo/bar.0000", stripped)
})
t.Run("/foo/bar.00001.ZIP", func(t *testing.T) {
regular := RelBase("/foo/bar.00001.ZIP", "/bar", false)
regular := RelPrefix("/foo/bar.00001.ZIP", "/bar", false)
assert.Equal(t, "/foo/bar.00001", regular)
stripped := RelBase("/foo/bar.00001.ZIP", "/bar", true)
stripped := RelPrefix("/foo/bar.00001.ZIP", "/bar", true)
assert.Equal(t, "/foo/bar", stripped)
})
t.Run("Test copy 3.jpg", func(t *testing.T) {
result := RelBase("/testdata/foo/Test copy 3.jpg", "/testdata", false)
result := RelPrefix("/testdata/foo/Test copy 3.jpg", "/testdata", false)
assert.Equal(t, "foo/Test copy 3", result)
})
t.Run("Test (3).jpg", func(t *testing.T) {
result := RelBase("/testdata/foo/Test (3).jpg", "/testdata", false)
result := RelPrefix("/testdata/foo/Test (3).jpg", "/testdata", false)
assert.Equal(t, "foo/Test (3)", result)
})
t.Run("Test (3).jpg", func(t *testing.T) {
result := RelBase("/testdata/foo/Test (3).jpg", "/testdata/foo/Test (3).jpg", false)
result := RelPrefix("/testdata/foo/Test (3).jpg", "/testdata/foo/Test (3).jpg", false)
assert.Equal(t, "Test (3)", result)
})
}
func TestBaseAbs(t *testing.T) {
t.Run("Test copy 3.jpg", func(t *testing.T) {
result := AbsBase("/testdata/Test (4).jpg", true)
result := AbsPrefix("/testdata/Test (4).jpg", true)
assert.Equal(t, "/testdata/Test", result)
})
t.Run("Test (3).jpg", func(t *testing.T) {
result := AbsBase("/testdata/Test (4).jpg", false)
result := AbsPrefix("/testdata/Test (4).jpg", false)
assert.Equal(t, "/testdata/Test (4)", result)
})

View file

@ -149,7 +149,7 @@ var TypeExt = FileExt.TypeExt()
// Find returns the first filename with the same base name and a given type.
func (t FileType) Find(fileName string, stripSequence bool) string {
base := Base(fileName, stripSequence)
base := BasePrefix(fileName, stripSequence)
dir := filepath.Dir(fileName)
prefix := filepath.Join(dir, base)
@ -187,7 +187,7 @@ func GetFileType(fileName string) FileType {
// FindFirst searches a list of directories for the first file with the same base name and a given type.
func (t FileType) FindFirst(fileName string, dirs []string, baseDir string, stripSequence bool) string {
fileBase := Base(fileName, stripSequence)
fileBase := BasePrefix(fileName, stripSequence)
fileBaseLower := strings.ToLower(fileBase)
fileBaseUpper := strings.ToUpper(fileBase)
@ -206,7 +206,7 @@ func (t FileType) FindFirst(fileName string, dirs []string, baseDir string, stri
if dir != fileDir {
if filepath.IsAbs(dir) {
dir = filepath.Join(dir, Rel(fileDir, baseDir))
dir = filepath.Join(dir, RelName(fileDir, baseDir))
} else {
dir = filepath.Join(fileDir, dir)
}

View file

@ -74,7 +74,7 @@ func IsGenerated(fileName string) bool {
return false
}
base := Base(fileName, false)
base := BasePrefix(fileName, false)
if IsHash(base) {
return true

View file

@ -10,13 +10,13 @@ import (
// FileName returns the a relative filename with the same base and a given extension in a directory.
func FileName(fileName, dirName, baseDir, fileExt string, stripSequence bool) string {
fileDir := filepath.Dir(fileName)
baseName := Base(fileName, stripSequence)
baseName := BasePrefix(fileName, stripSequence)
if dirName == "" || dirName == "." {
dirName = fileDir
} else if fileDir != dirName {
if filepath.IsAbs(dirName) {
dirName = filepath.Join(dirName, Rel(fileDir, baseDir))
dirName = filepath.Join(dirName, RelName(fileDir, baseDir))
} else {
dirName = filepath.Join(fileDir, dirName)
}
@ -32,8 +32,8 @@ func FileName(fileName, dirName, baseDir, fileExt string, stripSequence bool) st
return result
}
// Rel returns the file name relative to a directory.
func Rel(fileName, dir string) string {
// RelName returns the file name relative to a directory.
func RelName(fileName, dir string) string {
if fileName == dir {
return ""
}

View file

@ -10,25 +10,25 @@ import (
func TestRel(t *testing.T) {
t.Run("same", func(t *testing.T) {
assert.Equal(t, "", Rel("/some/path", "/some/path"))
assert.Equal(t, "", RelName("/some/path", "/some/path"))
})
t.Run("short", func(t *testing.T) {
assert.Equal(t, "/some/", Rel("/some/", "/some/path"))
assert.Equal(t, "/some/", RelName("/some/", "/some/path"))
})
t.Run("empty", func(t *testing.T) {
assert.Equal(t, "", Rel("", "/some/path"))
assert.Equal(t, "", RelName("", "/some/path"))
})
t.Run("/some/path", func(t *testing.T) {
assert.Equal(t, "foo/bar.baz", Rel("/some/path/foo/bar.baz", "/some/path"))
assert.Equal(t, "foo/bar.baz", RelName("/some/path/foo/bar.baz", "/some/path"))
})
t.Run("/some/path/", func(t *testing.T) {
assert.Equal(t, "foo/bar.baz", Rel("/some/path/foo/bar.baz", "/some/path/"))
assert.Equal(t, "foo/bar.baz", RelName("/some/path/foo/bar.baz", "/some/path/"))
})
t.Run("/some/path/bar", func(t *testing.T) {
assert.Equal(t, "/some/path/foo/bar.baz", Rel("/some/path/foo/bar.baz", "/some/path/bar"))
assert.Equal(t, "/some/path/foo/bar.baz", RelName("/some/path/foo/bar.baz", "/some/path/bar"))
})
t.Run("empty dir", func(t *testing.T) {
assert.Equal(t, "/some/path/foo/bar.baz", Rel("/some/path/foo/bar.baz", ""))
assert.Equal(t, "/some/path/foo/bar.baz", RelName("/some/path/foo/bar.baz", ""))
})
}

View file

@ -11,7 +11,7 @@ var FileTitleRegexp = regexp.MustCompile("[\\p{L}\\-,':]{2,}")
// FileTitle returns the string with the first characters of each word converted to uppercase.
func FileTitle(s string) string {
s = fs.Base(s, true)
s = fs.BasePrefix(s, true)
if len(s) < 3 {
return ""

View file

@ -29,6 +29,9 @@ sync
win
usb
dsc
dcf
dmc
lx
pic
pict
picture

View file

@ -34,6 +34,9 @@ var StopWords = map[string]bool{
"win": true,
"usb": true,
"dsc": true,
"dcf": true,
"dmc": true,
"lx": true,
"pic": true,
"pict": true,
"picture": true,