diff --git a/browser/files/localassets.go b/browser/files/localassets.go index 5caf630a..8272f912 100644 --- a/browser/files/localassets.go +++ b/browser/files/localassets.go @@ -2,6 +2,7 @@ package files import ( "context" + "errors" "io/fs" "path" "path/filepath" @@ -10,6 +11,7 @@ import ( "time" "github.com/simulot/immich-go/browser" + "github.com/simulot/immich-go/browser/picasa" "github.com/simulot/immich-go/helpers/fileevent" "github.com/simulot/immich-go/helpers/fshelper" "github.com/simulot/immich-go/helpers/gen" @@ -73,6 +75,11 @@ func (la *LocalAssetBrowser) Prepare(ctx context.Context) error { func (la *LocalAssetBrowser) passOneFsWalk(ctx context.Context, fsys fs.FS) error { la.catalogs[fsys] = map[string][]string{} + baseDir, ok := fsys.(fshelper.DirFS) + if !ok { + return errors.New("could not cast to fshelper.DirFS") + } + err := fs.WalkDir(fsys, ".", func(name string, d fs.DirEntry, err error) error { if err != nil { @@ -92,6 +99,15 @@ func (la *LocalAssetBrowser) passOneFsWalk(ctx context.Context, fsys fs.FS) erro if dir == "" { dir = "." } + + // TODO Add check for app.Picasa first. + // But `app` is not available here. so inject(?) it on app.ExploreLocalFolder() + if base == ".picasa.ini" { + picasa.CacheDirectory(filepath.Join(baseDir.Dir(), dir)) + la.log.Record(ctx, fileevent.DiscoveredPicasaIni, nil, name) + return nil + } + ext := filepath.Ext(base) mediaType := la.sm.TypeFromExt(ext) diff --git a/browser/picasa/picasa.go b/browser/picasa/picasa.go new file mode 100644 index 00000000..0d7887e4 --- /dev/null +++ b/browser/picasa/picasa.go @@ -0,0 +1,155 @@ +package picasa + +import ( + "bufio" + "errors" + "github.com/spf13/afero" + "log/slog" + "os" + "path" + "path/filepath" + "strings" +) + +type DirectoryData struct { + Name string + Description string + Location string + Files map[string]FileData + Albums map[string]AlbumData +} +type AlbumData struct { + Name string + Description string + Location string +} +type FileData struct { + IsStar bool + Caption string + Albums []string +} + +var appFS = afero.NewOsFs() + +var DirectoryCache = map[string]DirectoryData{} + +func CacheDirectory(dir string) { + DirectoryCache[dir] = ParseDirectory(dir) +} + +func HasPicasa(dir string) bool { + fileName := path.Join(dir, ".picasa.ini") + if _, err := os.Stat(fileName); errors.Is(err, os.ErrNotExist) { + return false + } + + return true +} + +func ParseDirectory(dir string) DirectoryData { + directoryData := DirectoryData{ + Files: map[string]FileData{}, + Albums: map[string]AlbumData{}, + } + + iniMap := parseFile(filepath.Join(dir, ".picasa.ini")) + for sectionName, pairs := range iniMap { + if sectionName == "Picasa" { + if value, ok := pairs["name"]; ok { + directoryData.Name = value + } + if value, ok := pairs["description"]; ok { + directoryData.Description = value + } + if value, ok := pairs["location"]; ok { + directoryData.Location = value + } + } else if strings.HasPrefix(sectionName, ".album:") { + albumData := AlbumData{} + token := sectionName[7:] + if value, ok := pairs["name"]; ok { + albumData.Name = value + } + if value, ok := pairs["description"]; ok { + albumData.Description = value + } + if value, ok := pairs["location"]; ok { + albumData.Location = value + } + directoryData.Albums[token] = albumData + } else { + fileData := FileData{} + if value, ok := pairs["star"]; ok { + fileData.IsStar = value == "yes" + } + if value, ok := pairs["caption"]; ok { + fileData.Caption = value + } + if value, ok := pairs["albums"]; ok { + fileData.Albums = strings.Split(value, ",") + } + directoryData.Files[sectionName] = fileData + } + } + + return directoryData +} + +func parseFile(path string) (result map[string]map[string]string) { + readFile, err := appFS.Open(path) + if err != nil { + slog.Error("could not open file", err) + return + } + defer func() { + err = readFile.Close() + if err != nil { + slog.Error("could not close file", err) + } + }() + + fileScanner := bufio.NewScanner(readFile) + return parseScanner(fileScanner) +} + +func parseScanner(fileScanner *bufio.Scanner) map[string]map[string]string { + result := map[string]map[string]string{} + fileScanner.Split(bufio.ScanLines) + + section := "" + for fileScanner.Scan() { + line := fileScanner.Text() + + if len(line) == 0 { + continue + } + + if line[0:1] == "[" { + end := strings.Index(line, "]") + section = line[1:end] + if _, ok := result[section]; !ok { + result[section] = map[string]string{} + } else { + panic("unexpected duplicate picasa.ini section. malformed .picasa.ini file?") + } + + } + if section != "" { + if eqPos := strings.Index(line, "="); eqPos > 0 { + key := line[0:eqPos] + value := line[eqPos+1:] + result[section][key] = value + } + } + } + + return result +} + +func (p DirectoryData) DescriptionAndLocation() string { + result := p.Description + if strings.TrimSpace(p.Location) != "" { + result += " Location: " + strings.TrimSpace(p.Location) + } + return result +} diff --git a/browser/picasa/picasa_test.go b/browser/picasa/picasa_test.go new file mode 100644 index 00000000..2bda01e5 --- /dev/null +++ b/browser/picasa/picasa_test.go @@ -0,0 +1,95 @@ +package picasa + +import ( + "bufio" + "fmt" + "github.com/psanford/memfs" + "github.com/spf13/afero" + "reflect" + "strings" + "testing" +) + +type inMemFS struct { + *memfs.FS + err error +} + +func newInMemFS() *inMemFS { + return &inMemFS{ + FS: memfs.New(), + } +} + +func Test2(t *testing.T) { + expected := DirectoryData{ + Name: "A Name", + Description: "A Description", + Location: "A Location", + Files: map[string]FileData{ + "file-name-1.jpg": {}, + "file-name-2.jpg": { + IsStar: true, + Caption: "A Caption", + }, + }, + Albums: map[string]AlbumData{}, + } + + sample := ` +[Picasa] +name=A Name +description=A Description +location=A Location + +[file-name-1.jpg] +some_other_key=some value + +[file-name-2.jpg] +star=yes +caption=A Caption +` + + appFS = afero.NewMemMapFs() + // create test files and directories + appFS.MkdirAll("sample", 0o755) + afero.WriteFile(appFS, "sample/.picasa.ini", []byte(sample), 0o644) + + actual := ParseDirectory("sample") + + if !reflect.DeepEqual(expected, actual) { + fmt.Printf("%+v\n", expected) + fmt.Printf("%+v\n", actual) + t.Error("ParseDirectory did not yield expected results") + } +} + +func TestReadLines(t *testing.T) { + sample := ` +[Picasa] +key1=value1 +key2=value2 + +[file-name.jpg] +key3=value3 +key4=value4 +` + buf := strings.NewReader(sample) + s := bufio.NewScanner(buf) + + actual := parseScanner(s) + expected := map[string]map[string]string{ + "Picasa": { + "key1": "value1", + "key2": "value2", + }, + "file-name.jpg": { + "key3": "value3", + "key4": "value4", + }, + } + + if !reflect.DeepEqual(actual, expected) { + t.Error("parsed ini did not match expected") + } +} diff --git a/cmd/upload/upload.go b/cmd/upload/upload.go index 8df140e2..4ce654a0 100644 --- a/cmd/upload/upload.go +++ b/cmd/upload/upload.go @@ -6,7 +6,9 @@ import ( "context" "flag" "fmt" + "github.com/simulot/immich-go/browser/picasa" "io/fs" + "log/slog" "math" "os" "path" @@ -38,6 +40,7 @@ type UpCmd struct { GooglePhotos bool // For reading Google Photos takeout files Delete bool // Delete original file after import CreateAlbumAfterFolder bool // Create albums for assets based on the parent folder or a given name + Picasa bool // Look for album and image metadata in .picasa.ini files ImportIntoAlbum string // All assets will be added to this album PartnerAlbum string // Partner's assets will be added to this album Import bool // Import instead of upload @@ -119,6 +122,10 @@ func newCommand(ctx context.Context, common *cmd.SharedFlags, args []string, fsO "create-album-folder", " folder import only: Create albums for assets based on the parent folder", myflag.BoolFlagFn(&app.CreateAlbumAfterFolder, false)) + cmd.BoolFunc( + "picasa", + " folder import only: Use picasa metadata for albums and assets", + myflag.BoolFlagFn(&app.Picasa, false)) cmd.BoolFunc( "google-photos", "Import GooglePhotos takeout zip files", @@ -451,6 +458,33 @@ func (app *UpCmd) handleAsset(ctx context.Context, a *browser.LocalAssetFile) er }) } + if app.Picasa { + if rootDir, ok := a.FSys.(fshelper.DirFS); ok { + if picasaDirectoryData, ok := picasa.DirectoryCache[path.Join(rootDir.Dir(), path.Dir(a.FileName))]; ok { + if fileData, ok := picasaDirectoryData.Files[filepath.Base(a.FileName)]; ok { + a.Metadata.Description = fileData.Caption + a.Favorite = fileData.IsStar + for _, token := range fileData.Albums { + if albumData, ok := picasaDirectoryData.Albums[token]; ok { + description := albumData.Description + if albumData.Location != "" { + description = strings.TrimSpace(description + " Location: " + albumData.Location) + } + a.Albums = append(a.Albums, browser.LocalAlbum{ + Title: albumData.Name, + Description: description, + }) + } else { + // NOTE: .picasa.ini seems to always define the album if it is referenced + // so hopefully, this warning never occurs. + slog.Warn("could not find album: ", token) + } + } + } + } + } + } + advice, err := app.AssetIndex.ShouldUpload(a) if err != nil { return err @@ -556,6 +590,8 @@ func (app *UpCmd) manageAssetAlbum(ctx context.Context, assetID string, a *brows } else { if app.CreateAlbumAfterFolder { album := path.Base(path.Dir(a.FileName)) + description := "" + if album == "" || album == "." { if fsys, ok := a.FSys.(fshelper.NameFS); ok { album = fsys.Name() @@ -563,9 +599,26 @@ func (app *UpCmd) manageAssetAlbum(ctx context.Context, assetID string, a *brows album = "no-folder-name" } } + + if app.Picasa { + if rootDir, ok := a.FSys.(fshelper.DirFS); ok { + if data, ok := picasa.DirectoryCache[path.Join(rootDir.Dir(), path.Dir(a.FileName))]; ok { + if data.Name != "" { + album = data.Name + } + if data.Description != "" { + description = data.Description + } + if data.Location != "" { + description = strings.TrimSpace(data.Description + " Location: " + data.Location) + } + } + } + } + app.Jnl.Record(ctx, fileevent.UploadAddToAlbum, a, a.FileName, "album", album, "reason", "option -create-album-folder") if !app.DryRun { - err := app.AddToAlbum(ctx, assetID, browser.LocalAlbum{Title: album}) + err := app.AddToAlbum(ctx, assetID, browser.LocalAlbum{Title: album, Description: description}) if err != nil { app.Jnl.Record(ctx, fileevent.Error, a, a.FileName, "error", err.Error()) } diff --git a/go.mod b/go.mod index f7424d00..6e9ba2e1 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/psanford/memfs v0.0.0-20230130182539-4dbf7e3e865e github.com/rivo/tview v0.0.0-20240616192244-23476fa0bab2 github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd + github.com/spf13/afero v1.11.0 github.com/telemachus/humane v0.6.0 github.com/thlib/go-timezone-local v0.0.3 github.com/ttacon/chalk v0.0.0-20160626202418-22c06c80ed31 diff --git a/go.sum b/go.sum index 501ed337..16e88108 100644 --- a/go.sum +++ b/go.sum @@ -57,6 +57,8 @@ github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZV github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/helpers/fileevent/fileevents.go b/helpers/fileevent/fileevents.go index f4da5cab..780d79d1 100644 --- a/helpers/fileevent/fileevents.go +++ b/helpers/fileevent/fileevents.go @@ -26,6 +26,7 @@ const ( DiscoveredSidecar // = "Scanned side car file" DiscoveredDiscarded // = "Discarded" DiscoveredUnsupported // = "File type not supported" + DiscoveredPicasaIni AnalysisAssociatedMetadata AnalysisMissingAssociatedMetadata @@ -54,6 +55,7 @@ var _code = map[Code]string{ DiscoveredSidecar: "scanned sidecar file", DiscoveredDiscarded: "discarded file", DiscoveredUnsupported: "unsupported file", + DiscoveredPicasaIni: "found picasa ini file", AnalysisAssociatedMetadata: "associated metadata file", AnalysisMissingAssociatedMetadata: "missing associated metadata file", diff --git a/helpers/fshelper/globwalkfs.go b/helpers/fshelper/globwalkfs.go index 1a841fde..9a31b4c6 100644 --- a/helpers/fshelper/globwalkfs.go +++ b/helpers/fshelper/globwalkfs.go @@ -140,6 +140,10 @@ func (gw GlobWalkFS) Name() string { return filepath.Base(gw.dir) } +func (gw GlobWalkFS) Dir() string { + return gw.dir +} + // FixedPathAndMagic split the path with the fixed part and the variable part func FixedPathAndMagic(name string) (string, string) { if !HasMagic(name) { @@ -204,3 +208,7 @@ func (f FSWithName) ReadFile(name string) ([]byte, error) { type NameFS interface { Name() string } + +type DirFS interface { + Dir() string +}