diff --git a/internal/config/customize/import.go b/internal/config/customize/import.go index e158dfc42..48d1696c6 100644 --- a/internal/config/customize/import.go +++ b/internal/config/customize/import.go @@ -1,7 +1,67 @@ package customize +import ( + "path/filepath" + "regexp" + "strings" + + "github.com/photoprism/photoprism/pkg/clean" +) + // ImportSettings represents import settings. type ImportSettings struct { Path string `json:"path" yaml:"Path"` Move bool `json:"move" yaml:"Move"` + Dest string `json:"dest" yaml:"Dest,omitempty"` +} + +// DefaultImportDest specifies the default import destination file path in the Originals folder. +// The date and time placeholders are described at https://pkg.go.dev/time#Layout. +var DefaultImportDest = "2006/01/20060102_150405_82F63B78.jpg" +var ImportDestRegexp = regexp.MustCompile("^(?P\\D*\\d{2,14}[\\-/_].*\\d{2,14}.*)(?P[0-9a-fA-F]{8})(?P\\.\\d{1,6}|\\.COUNT)?(?P\\.[0-9a-zA-Z]{2,8})$") + +// GetPath returns the default import source path, or a custom path if set. +func (s *ImportSettings) GetPath() string { + if s.Path != "" { + return s.Path + } + + return RootPath +} + +// GetDest returns the default file import destination, or a custom pattern if set and valid. +func (s *ImportSettings) GetDest() string { + if dest := strings.Trim(clean.Path(s.Dest), "/."); dest == "" || dest != s.Dest { + s.Dest = "" + } else if ImportDestRegexp.MatchString(dest) { + s.Dest = dest + return dest + } + + return DefaultImportDest +} + +// GetDestName returns the parsed import destination path and file name patterns. +func (s *ImportSettings) GetDestName() (pathName, fileName string) { + // Parse import destination pattern. + m := ImportDestRegexp.FindStringSubmatch(s.GetDest()) + + if len(m) < 4 { + return "", "" + } + + // Split file path into file and path name. + pathName, fileName = filepath.Split(m[ImportDestRegexp.SubexpIndex("name")]) + + // Make sure path and file name are not empty. + if pathName == "" || fileName == "" { + pathName, fileName = filepath.Split(ImportDestRegexp.FindStringSubmatch(DefaultImportDest)[ImportDestRegexp.SubexpIndex("name")]) + } + + // Make sure the file name pattern ends with "_" or "-". + if end := fileName[len(fileName)-1:]; end != "_" && end != "-" { + fileName += "_" + } + + return strings.Trim(pathName, "/. "), fileName } diff --git a/internal/config/customize/import_test.go b/internal/config/customize/import_test.go new file mode 100644 index 000000000..d669a2a6f --- /dev/null +++ b/internal/config/customize/import_test.go @@ -0,0 +1,149 @@ +package customize + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestImportSettings(t *testing.T) { + t.Run("Default", func(t *testing.T) { + s := ImportSettings{} + + assert.IsType(t, ImportSettings{}, s) + assert.Equal(t, "", s.Path) + assert.Equal(t, RootPath, s.GetPath()) + assert.Equal(t, false, s.Move) + assert.Equal(t, "", s.Dest) + assert.Equal(t, DefaultImportDest, s.GetDest()) + + pathName, fileName := s.GetDestName() + + assert.Equal(t, "2006/01", pathName) + assert.Equal(t, "20060102_150405_", fileName) + }) + t.Run("Customized", func(t *testing.T) { + customPath := "/foo/bar" + customDest := "2006/01/02/20060102_150405_82F63B78.jpg" + + s := ImportSettings{ + Path: customPath, + Move: true, + Dest: customDest, + } + + assert.IsType(t, ImportSettings{}, s) + assert.Equal(t, customPath, s.Path) + assert.Equal(t, customPath, s.GetPath()) + assert.Equal(t, true, s.Move) + assert.Equal(t, customDest, s.Dest) + assert.Equal(t, customDest, s.GetDest()) + + pathName, fileName := s.GetDestName() + + assert.Equal(t, "2006/01/02", pathName) + assert.Equal(t, "20060102_150405_", fileName) + }) + t.Run("InvalidDest", func(t *testing.T) { + customPath := "/foo/bar" + customDest := "/1/2/20060102_150405_82F63B78.jpg" + + s := ImportSettings{ + Path: customPath, + Move: true, + Dest: customDest, + } + + assert.IsType(t, ImportSettings{}, s) + assert.Equal(t, customPath, s.Path) + assert.Equal(t, customPath, s.GetPath()) + assert.Equal(t, true, s.Move) + assert.Equal(t, DefaultImportDest, s.GetDest()) + assert.Equal(t, "", s.Dest) + assert.Equal(t, DefaultImportDest, s.GetDest()) + + pathName, fileName := s.GetDestName() + + assert.Equal(t, "2006/01", pathName) + assert.Equal(t, "20060102_150405_", fileName) + }) +} + +func TestImportDestRegexp(t *testing.T) { + // Must match "^\\D*\\d{2,14}[\\-/_].*\\d{2,14}.*(\\.ext|\\.EXT)$" + t.Run("Valid", func(t *testing.T) { + assert.True(t, ImportDestRegexp.MatchString("2006/01/20060102_150405_82f63b78.jpg")) + assert.True(t, ImportDestRegexp.MatchString(DefaultImportDest)) + t.Logf("%s -> %s", DefaultImportDest, time.Now().Format("2006/01_02_150405_82F63B78.jpg")) + assert.True(t, ImportDestRegexp.MatchString(time.Now().Format(DefaultImportDest))) + assert.True(t, ImportDestRegexp.MatchString("foo/"+DefaultImportDest)) + assert.True(t, ImportDestRegexp.MatchString("/foo/"+DefaultImportDest)) + assert.True(t, ImportDestRegexp.MatchString("2006/01/20060102_150405_82F63B78.jpg")) + assert.True(t, ImportDestRegexp.MatchString("2006/01/02/150405_82F63B78.jpg")) + assert.True(t, ImportDestRegexp.MatchString("foo/2006/01_02_150405_82F63B78.jpg")) + assert.True(t, ImportDestRegexp.MatchString("/foo/2006/01_02_150405_82F63B78.jpg")) + assert.True(t, ImportDestRegexp.MatchString("2006/2/20060102_150405_82F63B78.jpg")) + assert.True(t, ImportDestRegexp.MatchString("2006/2/20060102_150405_82f63b78.COUNT.ext")) + assert.True(t, ImportDestRegexp.MatchString("2006/2_150405_82F63B78.jpg")) + + if m := ImportDestRegexp.FindStringSubmatch("2006/01/20060102_150405_82f63b78.00000.EXT"); m != nil { + t.Logf("Matches: %#v", m) + assert.Equal(t, "2006/01/20060102_150405_82f63b78.00000.EXT", m[0]) + assert.Equal(t, "2006/01/20060102_150405_", m[1]) + assert.Equal(t, "82f63b78", m[2]) + assert.Equal(t, ".00000", m[3]) + assert.Equal(t, ".EXT", m[4]) + } + + if m := ImportDestRegexp.FindStringSubmatch("2006/2/20060102_150405_82F63B78.jpg"); m != nil { + t.Logf("Matches: %#v", m) + assert.Equal(t, "2006/2/20060102_150405_82F63B78.jpg", m[0]) + assert.Equal(t, "2006/2/20060102_150405_", m[1]) + assert.Equal(t, "82F63B78", m[2]) + assert.Equal(t, "", m[3]) + assert.Equal(t, ".jpg", m[4]) + } + + if m := ImportDestRegexp.FindStringSubmatch("foo/2006/01_02_150405_82f63b78.COUNT.ext"); m != nil { + t.Logf("Matches: %#v", m) + assert.Equal(t, "foo/2006/01_02_150405_82f63b78.COUNT.ext", m[0]) + assert.Equal(t, "foo/2006/01_02_150405_", m[1]) + assert.Equal(t, "82f63b78", m[2]) + assert.Equal(t, ".COUNT", m[3]) + assert.Equal(t, ".ext", m[4]) + } + + assert.True(t, ImportDestRegexp.MatchString("2006/01/20060102_150405_82f63b78.00000.EXT")) + t.Logf("2006/01/20060102_150405_82f63b78.00000.EXT -> %s", time.Now().Format("2006/01/20060102_150405_82f63b78.00000.EXT")) + assert.True(t, ImportDestRegexp.MatchString(time.Now().Format("2006/01/20060102_150405_82f63b78.00000.EXT"))) + + assert.True(t, ImportDestRegexp.MatchString("2006/01/02/20060102_150405_82F63B78.jpg")) + t.Logf("2006/01/02/20060102_150405_82F63B78.jpg -> %s", time.Now().Format("2006/01/02/20060102_150405_82F63B78.jpg")) + assert.True(t, ImportDestRegexp.MatchString(time.Now().Format("2006/01/02/20060102_150405_82F63B78.jpg"))) + + assert.True(t, ImportDestRegexp.MatchString("2006/01_02_150405_82F63B78.jpg")) + t.Logf("2006/01_02_150405_82F63B78.jpg -> %s", time.Now().Format("2006/01_02_150405_82F63B78.jpg")) + assert.True(t, ImportDestRegexp.MatchString(time.Now().Format("2006/01_02_150405_82F63B78.jpg"))) + }) + t.Run("Invalid", func(t *testing.T) { + assert.False(t, ImportDestRegexp.MatchString("")) + assert.False(t, ImportDestRegexp.MatchString(DefaultImportDest+"foobar")) + assert.False(t, ImportDestRegexp.MatchString(DefaultImportDest+".foobar")) + assert.False(t, ImportDestRegexp.MatchString("1/2/20060102_150405_82F63B78.jpg")) + assert.False(t, ImportDestRegexp.MatchString("2006/2/CHECKSUM.ext")) + assert.False(t, ImportDestRegexp.MatchString("2006/2/bar.ext")) + assert.False(t, ImportDestRegexp.MatchString("2006/01/20060102_150405.ext")) + assert.False(t, ImportDestRegexp.MatchString("2006/01/20060102_150405_CRC32.ext")) + assert.False(t, ImportDestRegexp.MatchString("2006/01/20060102_150405.EXT")) + assert.False(t, ImportDestRegexp.MatchString("2006/01/02/150405.ext")) + assert.False(t, ImportDestRegexp.MatchString("2006/01/02/150405.EXT")) + assert.False(t, ImportDestRegexp.MatchString("2006/01_02_150405.ext")) + assert.False(t, ImportDestRegexp.MatchString("foo/2006/01_02_150405.ext")) + assert.False(t, ImportDestRegexp.MatchString("2006/01/02/150405-CRC32.ext")) + assert.False(t, ImportDestRegexp.MatchString("2006/01_02_150405_CRC32.ext")) + assert.False(t, ImportDestRegexp.MatchString("foo/2006/01_02_150405_CRC32.ext")) + assert.False(t, ImportDestRegexp.MatchString("2006/01/02/150405-CHECKSUM.EXT")) + assert.False(t, ImportDestRegexp.MatchString("2006/01_02_150405 CHECKSUM.ext")) + }) +} diff --git a/internal/config/customize/settings.go b/internal/config/customize/settings.go index 8074f1432..b9027a53c 100644 --- a/internal/config/customize/settings.go +++ b/internal/config/customize/settings.go @@ -83,6 +83,7 @@ func NewSettings(theme, lang string) *Settings { Import: ImportSettings{ Path: RootPath, Move: false, + Dest: "", }, Index: IndexSettings{ Path: RootPath, diff --git a/internal/entity/auth_user_settings_test.go b/internal/entity/auth_user_settings_test.go index f2cc017c5..bd058b0a2 100644 --- a/internal/entity/auth_user_settings_test.go +++ b/internal/entity/auth_user_settings_test.go @@ -3,8 +3,9 @@ package entity import ( "testing" - "github.com/photoprism/photoprism/internal/config/customize" "github.com/stretchr/testify/assert" + + "github.com/photoprism/photoprism/internal/config/customize" ) func TestCreateUserSettings(t *testing.T) { @@ -75,6 +76,7 @@ func TestUserSettings_Apply(t *testing.T) { Import: customize.ImportSettings{ Path: "imports/2023", Move: false, + Dest: customize.DefaultImportDest, }, } r := m.Apply(s) diff --git a/internal/photoprism/import.go b/internal/photoprism/import.go index 759163937..fee2465b5 100644 --- a/internal/photoprism/import.go +++ b/internal/photoprism/import.go @@ -289,8 +289,12 @@ func (imp *Import) Cancel() { } // DestinationFilename returns the destination filename of a MediaFile to be imported. +// Format: 2006/01/20060102_150405_CHECKSUM.ext func (imp *Import) DestinationFilename(mainFile *MediaFile, mediaFile *MediaFile, folder string) (string, error) { - fileName := mainFile.CanonicalName() + // Get the import destination path and file name patterns. + pathPattern, namePattern := imp.conf.Settings().Import.GetDestName() + + fileName := mainFile.CanonicalName(namePattern) fileExtension := mediaFile.Extension() dateCreated := mainFile.DateCreated() @@ -305,20 +309,20 @@ func (imp *Import) DestinationFilename(mainFile *MediaFile, mediaFile *MediaFile } } - // Find and return available filename. - iteration := 0 - dir := filepath.Join(imp.originalsPath(), folder, dateCreated.Format("2006/01")) - result := filepath.Join(dir, fileName+fileExtension) + // Find and return the next available file name if the default name is already being used by another file. + i := 0 + pathName := filepath.Join(imp.originalsPath(), folder, dateCreated.Format(pathPattern)) + filePath := filepath.Join(pathName, fileName+fileExtension) - for fs.FileExists(result) { - if mediaFile.Hash() == fs.Hash(result) { - return result, fmt.Errorf("%s already exists", clean.Log(fs.RelName(result, imp.originalsPath()))) + for fs.FileExists(filePath) { + if mediaFile.Hash() == fs.Hash(filePath) { + return filePath, fmt.Errorf("%s already exists", clean.Log(fs.RelName(filePath, imp.originalsPath()))) } - iteration++ + i++ - result = filepath.Join(dir, fileName+"."+fmt.Sprintf("%05d", iteration)+fileExtension) + filePath = filepath.Join(pathName, fileName+"."+fmt.Sprintf("%05d", i)+fileExtension) } - return result, nil + return filePath, nil } diff --git a/internal/photoprism/mediafile.go b/internal/photoprism/mediafile.go index 9c808a7ae..58ec4440e 100644 --- a/internal/photoprism/mediafile.go +++ b/internal/photoprism/mediafile.go @@ -280,8 +280,13 @@ func (m *MediaFile) Exposure() string { } // CanonicalName returns the canonical name of a media file. -func (m *MediaFile) CanonicalName() string { - return fs.CanonicalName(m.DateCreated(), m.Checksum()) +func (m *MediaFile) CanonicalName(pattern string) string { + return fs.CanonicalName(m.DateCreated(), m.Checksum(), pattern) +} + +// CanonicalNameDefault returns the default canonical name of a media file. +func (m *MediaFile) CanonicalNameDefault() string { + return fs.CanonicalName(m.DateCreated(), m.Checksum(), "") } // CanonicalNameFromFile returns the canonical name of a file derived from the image name. diff --git a/internal/photoprism/mediafile_test.go b/internal/photoprism/mediafile_test.go index 19f0500a6..26d22247b 100644 --- a/internal/photoprism/mediafile_test.go +++ b/internal/photoprism/mediafile_test.go @@ -364,18 +364,21 @@ func TestMediaFile_Exposure(t *testing.T) { c := config.TestConfig() t.Run("/cat_brown.jpg", func(t *testing.T) { - mediaFile, err := NewMediaFile(c.ExamplesPath() + "/cat_brown.jpg") + if err != nil { t.Fatal(err) } + assert.Equal(t, "1/50", mediaFile.Exposure()) }) t.Run("/elephants.jpg", func(t *testing.T) { mediaFile, err := NewMediaFile(c.ExamplesPath() + "/elephants.jpg") + if err != nil { t.Fatal(err) } + assert.Equal(t, "1/640", mediaFile.Exposure()) }) } @@ -384,29 +387,36 @@ func TestMediaFileCanonicalName(t *testing.T) { c := config.TestConfig() mediaFile, err := NewMediaFile(c.ExamplesPath() + "/beach_wood.jpg") + if err != nil { t.Fatal(err) } - assert.Equal(t, "20180111_110938_7D8F8A23", mediaFile.CanonicalName()) + + assert.Equal(t, "2018_01_11-11_09_38_7D8F8A23", mediaFile.CanonicalName("2006_01_02-15_04_05_")) + assert.Equal(t, "180111-110938_7D8F8A23", mediaFile.CanonicalName("060102-150405_")) + assert.Equal(t, "20180111_110938_7D8F8A23", mediaFile.CanonicalName("")) + assert.Equal(t, "20180111_110938_7D8F8A23", mediaFile.CanonicalNameDefault()) } func TestMediaFileCanonicalNameFromFile(t *testing.T) { - t.Run("/beach_wood.jpg", func(t *testing.T) { - conf := config.TestConfig() + c := config.TestConfig() + + t.Run("/beach_wood.jpg", func(t *testing.T) { + mediaFile, err := NewMediaFile(c.ExamplesPath() + "/beach_wood.jpg") - mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/beach_wood.jpg") if err != nil { t.Fatal(err) } + assert.Equal(t, "beach_wood", mediaFile.CanonicalNameFromFile()) }) t.Run("/airport_grey", func(t *testing.T) { - conf := config.TestConfig() + mediaFile, err := NewMediaFile(c.ExamplesPath() + "/airport_grey") - mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/airport_grey") if err != nil { t.Fatal(err) } + assert.Equal(t, "airport_grey", mediaFile.CanonicalNameFromFile()) }) } diff --git a/pkg/fs/canonical.go b/pkg/fs/canonical.go index a46662870..f48b91aa4 100644 --- a/pkg/fs/canonical.go +++ b/pkg/fs/canonical.go @@ -7,7 +7,7 @@ import ( // NonCanonical returns true if the file basename is NOT canonical. func NonCanonical(basename string) bool { - if len(basename) != 24 { + if l := len(basename); l != 22 && l != 24 { return true } @@ -28,12 +28,16 @@ func IsCanonical(basename string) bool { } // CanonicalName returns a canonical name based on time and CRC32 checksum. -func CanonicalName(date time.Time, checksum string) string { +func CanonicalName(date time.Time, checksum, pattern string) string { if len(checksum) != 8 { checksum = "EEEEEEEE" } else { checksum = strings.ToUpper(checksum) } - return date.Format("20060102_150405_") + checksum + if pattern == "" { + pattern = "20060102_150405_" + } + + return date.Format(pattern) + checksum } diff --git a/pkg/fs/canonical_test.go b/pkg/fs/canonical_test.go index 962582d53..d942e47f9 100644 --- a/pkg/fs/canonical_test.go +++ b/pkg/fs/canonical_test.go @@ -26,6 +26,7 @@ func TestCanonicalName(t *testing.T) { date := time.Date( 2009, 11, 17, 20, 34, 58, 651387237, time.UTC) - assert.Equal(t, "20091117_203458_EEEEEEEE", CanonicalName(date, "123")) - assert.Equal(t, "20091117_203458_12345678", CanonicalName(date, "12345678")) + assert.Equal(t, "20091117_203458_EEEEEEEE", CanonicalName(date, "123", "")) + assert.Equal(t, "20091117_203458_12345678", CanonicalName(date, "12345678", "")) + assert.Equal(t, "091117-203458_12345678", CanonicalName(date, "12345678", "060102-150405_")) }