diff --git a/internal/archive/archive.go b/internal/archive/archive.go index 7714552..3c2ee86 100644 --- a/internal/archive/archive.go +++ b/internal/archive/archive.go @@ -35,10 +35,17 @@ import ( "path/filepath" ) -// Generic archive interface. -type Archive interface { - Close() error +// Generic interface to access files inside an archive. +type ArchiveReader interface { + io.Closer + + // Open the file inside the archive identified by the given path. + // The path is relative to the archive's root. + // The caller must call [fs.File.Close] when finished using the file. Open(path string) (fs.File, error) + + // List files inside the archive which satisfy the given glob pattern. + // This method only returns files, not directories. Glob(pattern string) ([]FileInfo, error) } @@ -46,7 +53,7 @@ type Archive interface { // The archive can be a ZIP file or a directory. The implementation // will detect the type of archive and return the appropriate // implementation of the Archive interface. -func OpenArchive(path string) (Archive, error) { +func OpenArchive(path string) (ArchiveReader, error) { fi, err := os.Stat(path) if err != nil { return nil, err @@ -73,10 +80,14 @@ func OpenArchive(path string) (Archive, error) { // Interface for a file that can be opened when needed. type OpenableFile interface { + // Open the file for reading. + // The caller is responsible to call [io.ReadCloser.Close] when + // finished reading the file. Open() (io.ReadCloser, error) } // Generic information about a file inside an archive. +// This provides the filename and allows opening the file for reading. type FileInfo struct { Name string File OpenableFile @@ -115,6 +126,10 @@ func (a *zipArchive) Close() error { func (a *zipArchive) Glob(pattern string) ([]FileInfo, error) { result := make([]FileInfo, 0) for _, file := range a.zip.File { + if file.FileInfo().IsDir() { + continue + } + if matched, err := filepath.Match(pattern, file.Name); matched { if err != nil { return nil, err @@ -167,6 +182,14 @@ func (a *dirArchive) Glob(pattern string) ([]FileInfo, error) { } result := make([]FileInfo, 0) for _, name := range files { + stat, err := fs.Stat(a.dirFS, name) + if err != nil { + return nil, err + } + if stat.IsDir() { + continue + } + fullPath := filepath.Join(a.path, name) info := FileInfo{ Name: name, diff --git a/internal/archive/archive_test.go b/internal/archive/archive_test.go new file mode 100644 index 0000000..6f3f6db --- /dev/null +++ b/internal/archive/archive_test.go @@ -0,0 +1,189 @@ +/* +Copyright © 2025 Philipp Wolfer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package archive_test + +import ( + "fmt" + "io" + "log" + "slices" + "testing" + + "go.uploadedlobster.com/scotty/internal/archive" +) + +func ExampleOpenArchive() { + a, err := archive.OpenArchive("testdata/archive.zip") + if err != nil { + log.Fatal(err) + } + + defer a.Close() + + files, err := a.Glob("a/*.txt") + for _, fi := range files { + fmt.Println(fi.Name) + f, err := fi.File.Open() + if err != nil { + log.Fatal(err) + } + + defer f.Close() + data, err := io.ReadAll(f) + if err != nil { + log.Fatal(err) + } + fmt.Println(string(data)) + } + + // Output: a/1.txt + // a1 +} + +var testArchives = []string{ + "testdata/archive", + "testdata/archive.zip", +} + +func TestGlob(t *testing.T) { + for _, path := range testArchives { + a, err := archive.OpenArchive(path) + if err != nil { + t.Fatal(err) + } + defer a.Close() + + files, err := a.Glob("[ab]/1.txt") + if err != nil { + t.Fatal(err) + } + + if len(files) != 2 { + t.Errorf("Expected 2 files, got %d", len(files)) + } + + expectedName := "b/1.txt" + var fileInfo *archive.FileInfo = nil + for _, file := range files { + if file.Name == expectedName { + fileInfo = &file + } + } + + if fileInfo == nil { + t.Fatalf("Expected file %q to be found", expectedName) + } + + if fileInfo.File == nil { + t.Fatalf("Expected FileInfo to hold an openable File") + } + + f, err := fileInfo.File.Open() + if err != nil { + t.Fatal(err) + } + + expectedData := "b1\n" + data, err := io.ReadAll(f) + if err != nil { + t.Fatal(err) + } + if string(data) != expectedData { + fmt.Printf("%s: Expected file content to be %q, got %q", + path, expectedData, string(data)) + } + } +} + +func TestGlobAll(t *testing.T) { + for _, path := range testArchives { + a, err := archive.OpenArchive(path) + if err != nil { + t.Fatal(err) + } + defer a.Close() + + files, err := a.Glob("*/*") + if err != nil { + t.Fatal(err) + } + + filenames := make([]string, 0, len(files)) + for _, f := range files { + fmt.Printf("%v: %v\n", path, f.Name) + filenames = append(filenames, f.Name) + } + + slices.Sort(filenames) + + expectedFilenames := []string{ + "a/1.txt", + "b/1.txt", + "b/2.txt", + } + if !slices.Equal(filenames, expectedFilenames) { + t.Errorf("%s: Expected filenames to be %q, got %q", + path, expectedFilenames, filenames) + } + } +} + +func TestOpen(t *testing.T) { + for _, path := range testArchives { + a, err := archive.OpenArchive(path) + if err != nil { + t.Fatal(err) + } + defer a.Close() + + f, err := a.Open("b/2.txt") + if err != nil { + t.Fatal(err) + } + + expectedData := "b2\n" + data, err := io.ReadAll(f) + if err != nil { + t.Fatal(err) + } + if string(data) != expectedData { + fmt.Printf("%s: Expected file content to be %q, got %q", + path, expectedData, string(data)) + } + } +} + +func TestOpenError(t *testing.T) { + for _, path := range testArchives { + a, err := archive.OpenArchive(path) + if err != nil { + t.Fatal(err) + } + defer a.Close() + + _, err = a.Open("b/3.txt") + if err == nil { + t.Errorf("%s: Expected the Open command to fail", path) + } + } +} diff --git a/internal/archive/testdata/archive.zip b/internal/archive/testdata/archive.zip new file mode 100644 index 0000000..19923f6 Binary files /dev/null and b/internal/archive/testdata/archive.zip differ diff --git a/internal/archive/testdata/archive/a/1.txt b/internal/archive/testdata/archive/a/1.txt new file mode 100644 index 0000000..da0f8ed --- /dev/null +++ b/internal/archive/testdata/archive/a/1.txt @@ -0,0 +1 @@ +a1 diff --git a/internal/archive/testdata/archive/b/1.txt b/internal/archive/testdata/archive/b/1.txt new file mode 100644 index 0000000..c9c6af7 --- /dev/null +++ b/internal/archive/testdata/archive/b/1.txt @@ -0,0 +1 @@ +b1 diff --git a/internal/archive/testdata/archive/b/2.txt b/internal/archive/testdata/archive/b/2.txt new file mode 100644 index 0000000..e6bfff5 --- /dev/null +++ b/internal/archive/testdata/archive/b/2.txt @@ -0,0 +1 @@ +b2 diff --git a/internal/backends/spotifyhistory/archive.go b/internal/backends/spotifyhistory/archive.go index 1d596bd..cb53772 100644 --- a/internal/backends/spotifyhistory/archive.go +++ b/internal/backends/spotifyhistory/archive.go @@ -33,7 +33,7 @@ var historyFileGlobs = []string{ // This can be either the ZIP file as provided by Spotify // or a directory where this was extracted to. type HistoryArchive struct { - backend archive.Archive + backend archive.ArchiveReader } // Open a Spotify history archive from file path. diff --git a/internal/listenbrainz/archive.go b/internal/listenbrainz/archive.go index b7b5909..57c3ad8 100644 --- a/internal/listenbrainz/archive.go +++ b/internal/listenbrainz/archive.go @@ -40,7 +40,7 @@ import ( // The export contains the user's listen history, favorite tracks and // user information. type ExportArchive struct { - backend archive.Archive + backend archive.ArchiveReader } // Open a ListenBrainz archive from file path.