diff --git a/internal/webindexer/local.go b/internal/webindexer/local.go index c769a38..fac5e7c 100644 --- a/internal/webindexer/local.go +++ b/internal/webindexer/local.go @@ -16,8 +16,8 @@ type LocalBackend struct { var _ FileSource = &LocalBackend{} -func (l *LocalBackend) Read(path string) ([]Item, bool, error) { - var items []Item +func (l *LocalBackend) Read(path string) ([]*Item, bool, error) { + var items []*Item log.Debugf("Listing files in %s", path) files, err := os.ReadDir(path) if err != nil { @@ -38,7 +38,7 @@ func (l *LocalBackend) Read(path string) ([]Item, bool, error) { log.Infof("Skipping indexing of %s (found skipindex file %s), will include in parent directory", path, file.Name()) // Return empty items but mark as not having noindex file // This will prevent indexing this directory but still include it in the parent - return []Item{}, false, nil + return []*Item{}, false, nil } } } @@ -76,15 +76,13 @@ func (l *LocalBackend) Read(path string) ([]Item, bool, error) { } } - size := humanizeBytes(stat.Size()) - modified := stat.ModTime().Format(l.cfg.DateFormat) - itemName := file.Name() - item := Item{ + item := &Item{ Name: itemName, - Size: size, - LastModified: modified, + Size: stat.Size(), + LastModified: stat.ModTime(), IsDir: stat.IsDir(), + HasMetadata: true, } items = append(items, item) diff --git a/internal/webindexer/s3.go b/internal/webindexer/s3.go index cc2a280..7fe9cbf 100644 --- a/internal/webindexer/s3.go +++ b/internal/webindexer/s3.go @@ -23,7 +23,7 @@ type S3API interface { var _ FileSource = &S3Backend{} -func (s *S3Backend) Read(prefix string) ([]Item, bool, error) { +func (s *S3Backend) Read(prefix string) ([]*Item, bool, error) { // Ensure the prefix has a trailing slash for s3 keys if !strings.HasSuffix(prefix, "/") { prefix = prefix + "/" @@ -62,11 +62,11 @@ func (s *S3Backend) Read(prefix string) ([]Item, bool, error) { log.Infof("Skipping indexing of %s/%s (found skipindex file %s), will include in parent directory", s.bucket, prefix, fileName) // Return empty items but mark as not having noindex file // This will prevent indexing this directory but still include it in the parent - return []Item{}, false, nil + return []*Item{}, false, nil } } - var items []Item + var items []*Item // Process all other files for _, content := range resp.Contents { if shouldSkip(*content.Key, s.cfg.IndexFile, s.cfg.Skips) { @@ -76,11 +76,12 @@ func (s *S3Backend) Read(prefix string) ([]Item, bool, error) { // Get the relative name by removing the prefix itemName := strings.TrimPrefix(*content.Key, prefix) - item := Item{ + item := &Item{ Name: itemName, - Size: humanizeBytes(*content.Size), - LastModified: content.LastModified.Format(s.cfg.DateFormat), + Size: *content.Size, + LastModified: *content.LastModified, IsDir: false, + HasMetadata: true, } items = append(items, item) @@ -117,7 +118,7 @@ func (s *S3Backend) Read(prefix string) ([]Item, bool, error) { } dirName := strings.TrimPrefix(*commonPrefix.Prefix, prefix) - item := Item{ + item := &Item{ Name: dirName, IsDir: true, } diff --git a/internal/webindexer/sort.go b/internal/webindexer/sort.go index 7763592..61d1608 100644 --- a/internal/webindexer/sort.go +++ b/internal/webindexer/sort.go @@ -6,7 +6,7 @@ import ( "unicode" ) -func (i *Indexer) sort(items *[]Item) { +func (i *Indexer) sort(items *[]TemplateItem) { switch i.Cfg.SortByValue() { case SortByDate: orderByLastModified(items) @@ -27,13 +27,13 @@ func (i *Indexer) sort(items *[]Item) { } } -func orderByName(items *[]Item) { +func orderByName(items *[]TemplateItem) { sort.SliceStable(*items, func(i, j int) bool { return (*items)[i].Name < (*items)[j].Name }) } -func orderByLastModified(items *[]Item) { +func orderByLastModified(items *[]TemplateItem) { sort.SliceStable(*items, func(i, j int) bool { return (*items)[i].LastModified > (*items)[j].LastModified }) @@ -41,7 +41,7 @@ func orderByLastModified(items *[]Item) { // orderByNaturalName sorts items by their names with numbers ordered // naturally. e.g. 1,2,10 instead of 1,10,2 or 0.8.2 before 0.8.10 -func orderByNaturalName(items *[]Item) { +func orderByNaturalName(items *[]TemplateItem) { sort.SliceStable(*items, func(i, j int) bool { return cmpNatural((*items)[i].Name, (*items)[j].Name) }) @@ -95,7 +95,7 @@ func cmpNatural(a, b string) bool { return len(aSegments) < len(bSegments) } -func orderDirsFirst(items *[]Item) { +func orderDirsFirst(items *[]TemplateItem) { sort.SliceStable(*items, func(i, j int) bool { if (*items)[i].IsDir && !(*items)[j].IsDir { return true diff --git a/internal/webindexer/sort_test.go b/internal/webindexer/sort_test.go index cd875c3..5778048 100644 --- a/internal/webindexer/sort_test.go +++ b/internal/webindexer/sort_test.go @@ -1,9 +1,11 @@ package webindexer -import "testing" +import ( + "testing" +) func TestOrderByName(t *testing.T) { - items := []Item{ + items := []TemplateItem{ {Name: "banana"}, {Name: "apple"}, {Name: "cherry"}, @@ -19,7 +21,7 @@ func TestOrderByName(t *testing.T) { } func TestOrderByLastModified(t *testing.T) { - items := []Item{ + items := []TemplateItem{ {Name: "banana", LastModified: "2020-01-03"}, {Name: "apple", LastModified: "2020-01-01"}, {Name: "cherry", LastModified: "2020-01-02"}, @@ -35,7 +37,7 @@ func TestOrderByLastModified(t *testing.T) { } func TestOrderByNaturalName(t *testing.T) { - items := []Item{ + items := []TemplateItem{ {Name: "item10"}, {Name: "item2"}, {Name: "item1"}, @@ -51,7 +53,7 @@ func TestOrderByNaturalName(t *testing.T) { } func TestOrderDirsFirst(t *testing.T) { - items := []Item{ + items := []TemplateItem{ {Name: "file.txt", IsDir: false}, {Name: "folder", IsDir: true}, {Name: "another_folder", IsDir: true}, diff --git a/internal/webindexer/templates/themes/default.html.tmpl b/internal/webindexer/templates/themes/default.html.tmpl index cf44f75..b6e8378 100644 --- a/internal/webindexer/templates/themes/default.html.tmpl +++ b/internal/webindexer/templates/themes/default.html.tmpl @@ -65,11 +65,9 @@ {{ if .ParentURL }} - + 🔼Go Up - - - - {{end}} {{range .Items}} @@ -85,14 +83,14 @@ {{.Name}} - {{if not .IsDir}} + {{if .Size }} {{.Size}} {{else}} - {{end}} - {{if not .IsDir}} + {{if .LastModified}} {{.LastModified}} {{else}} - diff --git a/internal/webindexer/templates/themes/dracula.html.tmpl b/internal/webindexer/templates/themes/dracula.html.tmpl index 719d752..4c9b064 100644 --- a/internal/webindexer/templates/themes/dracula.html.tmpl +++ b/internal/webindexer/templates/themes/dracula.html.tmpl @@ -124,11 +124,9 @@ {{ if .ParentURL }} - + 🔼Go Up - - - - {{end}} {{range .Items}} @@ -144,14 +142,14 @@ {{.Name}} - {{if not .IsDir}} + {{if .Size}} {{.Size}} {{else}} - {{end}} - {{if not .IsDir}} + {{if .LastModified}} {{.LastModified}} {{else}} - diff --git a/internal/webindexer/templates/themes/nord.html.tmpl b/internal/webindexer/templates/themes/nord.html.tmpl index ed31d61..28cf619 100644 --- a/internal/webindexer/templates/themes/nord.html.tmpl +++ b/internal/webindexer/templates/themes/nord.html.tmpl @@ -135,11 +135,9 @@ {{ if .ParentURL }} - + 🔼Go Up - - - - {{end}} {{range .Items}} @@ -155,14 +153,14 @@ {{.Name}} - {{if not .IsDir}} + {{if .Size}} {{.Size}} {{else}} - {{end}} - {{if not .IsDir}} + {{if .LastModified}} {{.LastModified}} {{else}} - diff --git a/internal/webindexer/templates/themes/solarized.html.tmpl b/internal/webindexer/templates/themes/solarized.html.tmpl index 836cc27..4c264b3 100644 --- a/internal/webindexer/templates/themes/solarized.html.tmpl +++ b/internal/webindexer/templates/themes/solarized.html.tmpl @@ -128,11 +128,9 @@ {{ if .ParentURL }} - + 🔼Go Up - - - - {{end}} {{range .Items}} @@ -148,14 +146,14 @@ {{.Name}} - {{if not .IsDir}} + {{if .Size}} {{.Size}} {{else}} - {{end}} - {{if not .IsDir}} + {{if .LastModified}} {{.LastModified}} {{else}} - diff --git a/internal/webindexer/webindexer.go b/internal/webindexer/webindexer.go index e2af563..c93aec5 100644 --- a/internal/webindexer/webindexer.go +++ b/internal/webindexer/webindexer.go @@ -11,6 +11,7 @@ import ( "path" "path/filepath" "strings" + "time" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/s3" @@ -41,7 +42,7 @@ type Indexer struct { // FileSource is an interface for listing the contents of a directory or S3 // bucket. type FileSource interface { - Read(path string) ([]Item, bool, error) + Read(path string) ([]*Item, bool, error) Write(data Data, content string) error EnsureDirExists(relativePath string) error } @@ -49,11 +50,12 @@ type FileSource interface { // Item represents an S3 key, or a local file/directory. type Item struct { Name string - Size string - LastModified string + Size int64 + LastModified time.Time URL string IsDir bool Items []Item + HasMetadata bool } // Data holds the template data. @@ -63,12 +65,20 @@ type Data struct { RootPath string RelativePath string URL string - Items []Item + Items []TemplateItem Parent string HasParent bool ParentURL string } +type TemplateItem struct { + Name string + Size string + LastModified string + URL string + IsDir bool +} + type BackendSetup interface { Setup(indexer *Indexer) error } @@ -199,7 +209,7 @@ func setupBackend(uri string, indexer *Indexer) (FileSource, error) { } // Generate the index file for the given path. -func (i Indexer) Generate(path string) error { +func (i Indexer) Generate(parent *Item, path string) error { var err error items, hasNoIndex, err := i.Source.Read(path) @@ -213,17 +223,48 @@ func (i Indexer) Generate(path string) error { return nil } + // Ensure the target directory exists before attempting to write or recurse + relativePath := strings.TrimPrefix(path, i.Cfg.BasePath) + + // Ensure relative path is prefixed with a slash. This will also set an + // empty base path to "/" (such as when listing the root of an S3 bucket). + // S3 keys don't have a leading slash, but we normalize for consistency + if !strings.HasPrefix(relativePath, "/") { + relativePath = "/" + relativePath + } + + if err := i.Target.EnsureDirExists(relativePath); err != nil { + return fmt.Errorf("failed to ensure target directory exists for %s: %w", relativePath, err) + } + + // Process items to handle recursion. + // This loop won't execute if items is empty. + for _, item := range items { // Iterate over original items + err := i.parseItem(path, item) // Pass item by value, check error + if err != nil { + // Stop processing if any subdirectory fails? Or just log? + // Return the error to propagate it up. + log.Errorf("Error generating index for %s: %v", item.Name, err) + return err + } + + if parent != nil { + parent.Size += item.Size + + if !parent.HasMetadata || item.LastModified.After(parent.LastModified) { + parent.LastModified = item.LastModified + } + + parent.HasMetadata = true + } + } + // Prepare template data regardless of whether items were found - data, err := i.data(items, path) + data, err := i.data(items, path, relativePath) if err != nil { return err } - // Ensure the target directory exists before attempting to write or recurse - if err := i.Target.EnsureDirExists(data.RelativePath); err != nil { - return fmt.Errorf("failed to ensure target directory exists for %s: %w", data.RelativePath, err) - } - // Only generate and write the index file if there are items to list. // This handles the skipindex case (Read returns empty items) and empty directories. if len(items) > 0 { @@ -264,17 +305,6 @@ func (i Indexer) Generate(path string) error { log.Debugf("Skipping index file generation for %s (no items or skipindex found)", path) } - // Process items to handle recursion. - // This loop won't execute if items is empty. - for _, item := range items { // Iterate over original items - err := i.parseItem(path, item) // Pass item by value, check error - if err != nil { - // Stop processing if any subdirectory fails? Or just log? - // Return the error to propagate it up. - return err - } - } - return nil } @@ -292,19 +322,10 @@ func getThemeTemplate(theme string) string { } } -func (i Indexer) data(items []Item, path string) (Data, error) { - relativePath := strings.TrimPrefix(path, i.Cfg.BasePath) - - // Ensure relative path is prefixed with a slash. This will also set an - // empty base path to "/" (such as when listing the root of an S3 bucket). - // S3 keys don't have a leading slash, but we normalize for consistency - if !strings.HasPrefix(relativePath, "/") { - relativePath = "/" + relativePath - } - +func (i Indexer) data(items []*Item, path, relativePath string) (Data, error) { data := Data{ RootPath: i.Cfg.BasePath, - Items: make([]Item, 0, len(items)), + Items: make([]TemplateItem, 0, len(items)), Path: path, RelativePath: relativePath, URL: i.Cfg.BaseURL, @@ -328,7 +349,7 @@ func (i Indexer) data(items []Item, path string) (Data, error) { } // Process items within the data function to set their URLs - processedItems := make([]Item, 0, len(items)) + processedItems := make([]TemplateItem, 0, len(items)) for _, item := range items { processedItem, err := i.processItemForData(path, item) // Rename to avoid confusion with recursive call if err != nil { @@ -343,7 +364,7 @@ func (i Indexer) data(items []Item, path string) (Data, error) { } // processItemForData generates the URL for an item. Does NOT handle recursion. -func (i Indexer) processItemForData(path string, item Item) (Item, error) { +func (i Indexer) processItemForData(path string, item *Item) (TemplateItem, error) { // Calculate the relative path by removing the base path relativePath := strings.TrimPrefix(path, i.Cfg.BasePath) // Ensure relative path is prefixed with a slash @@ -351,18 +372,27 @@ func (i Indexer) processItemForData(path string, item Item) (Item, error) { relativePath = "/" + relativePath } - item.URL = resolveItemURL(i.Cfg.BaseURL, relativePath, item.Name, item.IsDir, i.Cfg.LinkToIndexes, i.Cfg.IndexFile) - // Return the item with the URL set - return item, nil + processed := TemplateItem{ + Name: item.Name, + URL: resolveItemURL(i.Cfg.BaseURL, relativePath, item.Name, item.IsDir, i.Cfg.LinkToIndexes, i.Cfg.IndexFile), + IsDir: item.IsDir, + } + + if item.HasMetadata { + processed.Size = humanizeBytes(item.Size) + processed.LastModified = item.LastModified.Format(i.Cfg.DateFormat) + } + + return processed, nil } // parseItem handles the recursive call for directories. -func (i Indexer) parseItem(path string, item Item) error { +func (i Indexer) parseItem(path string, item *Item) error { // If the item is a directory and recursive mode is enabled, generate its index if item.IsDir && i.Cfg.Recursive { // Construct the full path for the subdirectory subDirPath := filepath.Join(path, item.Name) - if err := i.Generate(subDirPath); err != nil { + if err := i.Generate(item, subDirPath); err != nil { // Log the error but also return it to stop processing this branch log.Errorf("Error generating index for subdirectory %s: %v", subDirPath, err) return fmt.Errorf("error generating index for subdirectory %s: %w", subDirPath, err) diff --git a/internal/webindexer/webindexer_test.go b/internal/webindexer/webindexer_test.go index 5df0be0..9ce53af 100644 --- a/internal/webindexer/webindexer_test.go +++ b/internal/webindexer/webindexer_test.go @@ -19,9 +19,9 @@ type MockSource struct { mock.Mock } -func (m *MockSource) Read(path string) ([]Item, bool, error) { +func (m *MockSource) Read(path string) ([]*Item, bool, error) { args := m.Called(path) - return args.Get(0).([]Item), args.Bool(1), args.Error(2) + return args.Get(0).([]*Item), args.Bool(1), args.Error(2) } func (m *MockSource) Write(data Data, content string) error { @@ -45,13 +45,13 @@ func TestIndexer_Generate(t *testing.T) { }, } - mockSource.On("Read", mock.Anything).Return([]Item{}, false, nil) + mockSource.On("Read", mock.Anything).Return([]*Item{}, false, nil) // Expect EnsureDirExists to be called on the target mockTarget.On("EnsureDirExists", mock.AnythingOfType("string")).Return(nil) // Write should NOT be called when Read returns empty items // mockTarget.On("Write", mock.Anything, mock.Anything).Return(nil) - err := indexer.Generate("path/to/generate") + err := indexer.Generate(nil, "path/to/generate") assert.NoError(t, err) mockSource.AssertExpectations(t) @@ -88,13 +88,13 @@ func TestCustomTemplate(t *testing.T) { require.NoError(t, err) - mockSource.On("Read", mock.Anything).Return([]Item{}, false, nil) + mockSource.On("Read", mock.Anything).Return([]*Item{}, false, nil) // Expect EnsureDirExists to be called on the target before writing mockTarget.On("EnsureDirExists", mock.AnythingOfType("string")).Return(nil) // Write should NOT be called when Read returns empty items // mockTarget.On("Write", mock.Anything, mock.Anything).Return(nil) - err = indexer.Generate("path/to/generate") + err = indexer.Generate(nil, "path/to/generate") assert.NoError(t, err) // Check the file content @@ -298,8 +298,8 @@ func TestIndexer_ProcessItemForData(t *testing.T) { // No need to call setupBackends as we manually set BasePath // Simulate items - itemDir := Item{Name: "dir", IsDir: true} - itemFile1 := Item{Name: "file1.txt", IsDir: false} + itemDir := &Item{Name: "dir", IsDir: true} + itemFile1 := &Item{Name: "file1.txt", IsDir: false} // Test directory item modifiedItemDir, err := indexer.processItemForData(sourceDir, itemDir) @@ -437,7 +437,7 @@ func TestGenerate_Recursive(t *testing.T) { }), mock.AnythingOfType("string")).Return(nil).Once() // 5. Call Generate from the root source path - err = indexer.Generate(absSourceDir) + err = indexer.Generate(nil, absSourceDir) require.NoError(t, err) // 6. Assert mock expectations were met diff --git a/main.go b/main.go index 345dec4..4994418 100644 --- a/main.go +++ b/main.go @@ -88,7 +88,7 @@ func run(args []string) error { } log.Infof("Generating index for %s", cfg.Source) - err = indexer.Generate(indexer.Cfg.BasePath) + err = indexer.Generate(nil, indexer.Cfg.BasePath) if err != nil { return fmt.Errorf("unable to generate index: %w", err) }