commit c3b57c02e35b7649e88c121f3ecc9305f9344d71 Author: Pim van Pelt Date: Tue Dec 2 23:47:17 2025 +0100 Initial checkin diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4531594 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +s3-genindex + +# Debian packaging artifacts +debian/.debhelper/ +debian/.gocache/ +debian/go/ +debian/s3-genindex/ +debian/files +debian/*.substvars +debian/debhelper-build-stamp +debian/*.debhelper diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0a04128 --- /dev/null +++ b/LICENSE @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4c5e4e7 --- /dev/null +++ b/Makefile @@ -0,0 +1,89 @@ +.PHONY: build clean wipe test help + +# Default target +all: build + +# Build the binary +build: + @echo "Building s3-genindex..." + go build -o s3-genindex ./cmd/s3-genindex + @echo "Build complete: s3-genindex" + +# Run tests +test: + @echo "Running tests..." + go test -v ./... + @echo "Tests complete" + +# Run tests with coverage +test-coverage: + @echo "Running tests with coverage..." + go test -v -coverprofile=coverage.out ./... + go tool cover -html=coverage.out -o coverage.html + @echo "Coverage report generated: coverage.html" + +# Clean build artifacts +clean: + @echo "Cleaning build artifacts..." + rm -f s3-genindex + rm -f coverage.out coverage.html + @echo "Clean complete" + +# Wipe everything including test caches +wipe: clean + @echo "Wiping test cache and module cache..." + go clean -testcache + go clean -modcache + @echo "Wipe complete" + +# Install binary to $GOPATH/bin or $GOBIN +install: + @echo "Installing s3-genindex..." + go install ./cmd/s3-genindex + @echo "Install complete" + +# Format code +fmt: + @echo "Formatting code..." + go fmt ./... + @echo "Format complete" + +# Vet code for issues +vet: + @echo "Vetting code..." + go vet ./... + @echo "Vet complete" + +# Lint code (requires golangci-lint) +lint: + @echo "Linting code..." + @if command -v golangci-lint >/dev/null 2>&1; then \ + golangci-lint run; \ + else \ + echo "golangci-lint not found, skipping lint"; \ + fi + +# Run all checks +check: fmt vet lint test + @echo "All checks passed" + +# Run benchmarks +bench: + @echo "Running benchmarks..." + go test -bench=. ./... + +# Show help +help: + @echo "Available targets:" + @echo " build - Build the s3-genindex binary" + @echo " test - Run all tests" + @echo " test-coverage - Run tests with coverage report" + @echo " clean - Remove build artifacts" + @echo " wipe - Clean everything including caches" + @echo " install - Install binary to GOPATH/bin" + @echo " fmt - Format code" + @echo " vet - Vet code for issues" + @echo " lint - Lint code (requires golangci-lint)" + @echo " check - Run fmt, vet, lint, and test" + @echo " bench - Run benchmarks" + @echo " help - Show this help message" \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..20fe805 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# s3-genindex + +Generate HTML directory indexes with file type icons and responsive design. + +## Install + +```bash +go install git.ipng.ch/ipng/s3-genindex/cmd/s3-genindex@latest +``` + +## Usage + +```bash +# Generate index.html in current directory +s3-genindex + +# Generate recursively with custom output +s3-genindex -r -o listing.html /path/to/dir + +# Exclude files by regex +s3-genindex -x "(build|node_modules|\.tmp)" +``` + +## Build + +```bash +make build +make test +``` + +See [docs/DETAILS.md](docs/DETAILS.md) for complete documentation. \ No newline at end of file diff --git a/docs/DETAILS.md b/docs/DETAILS.md new file mode 100644 index 0000000..de5972a --- /dev/null +++ b/docs/DETAILS.md @@ -0,0 +1,288 @@ +# s3-genindex - Detailed Documentation + +## Overview + +s3-genindex is a Go rewrite of the original Python genindex.py script. It generates HTML directory listings with file type icons, responsive design, and dark mode support. + +## Features + +- **File Type Detection**: Recognizes 100+ file extensions with appropriate icons +- **Responsive Design**: Works on desktop and mobile devices +- **Dark Mode**: Automatic dark mode support based on system preferences +- **Recursive Processing**: Generate indexes for entire directory trees +- **File Filtering**: Include/exclude files by pattern or regex +- **Symlink Support**: Special handling for symbolic links +- **Custom Output**: Configurable output filename +- **Breadcrumb Navigation**: Parent directory navigation + +## Installation + +### From Source + +```bash +git clone https://git.ipng.ch/ipng/s3-genindex.git +cd s3-genindex +make build +``` + +### Using Go Install + +```bash +go install git.ipng.ch/ipng/s3-genindex/cmd/s3-genindex@latest +``` + +## Command Line Options + +``` +Usage: s3-genindex [OPTIONS] [directory] + + -d, --dir-append append output file to directory href + -f, --filter string only include files matching glob (default "*") + -i, --include-hidden include dot hidden files + -o, --output-file string custom output file (default "index.html") + -r, --recursive recursively process nested dirs + -v, --verbose verbosely list every processed file + -x, --exclude-regex string exclude files matching regular expression + --top-dir string top folder from which to start generating indexes +``` + +## Usage Examples + +### Basic Usage + +```bash +# Generate index.html in current directory +s3-genindex + +# Generate index for specific directory +s3-genindex /path/to/directory + +# Generate with custom output filename +s3-genindex -o listing.html +``` + +### Recursive Processing + +```bash +# Process directory tree recursively +s3-genindex -r + +# Process recursively with verbose output +s3-genindex -rv /var/www +``` + +### File Filtering + +```bash +# Include only Python files +s3-genindex --filter "*.py" + +# Exclude build artifacts and dependencies +s3-genindex -x "(build|dist|node_modules|__pycache__|\\.tmp)" + +# Include hidden files +s3-genindex -i +``` + +### Advanced Usage + +```bash +# Recursive with custom output and exclusions +s3-genindex -r -o index.html -x "(\.git|\.svn|node_modules)" /var/www + +# Verbose processing with directory appending +s3-genindex -rv --dir-append /home/user/public +``` + +## File Type Support + +The tool recognizes and provides appropriate icons for: + +### Programming Languages +- Go (`.go`) +- Python (`.py`, `.pyc`, `.pyo`) +- JavaScript/TypeScript (`.js`, `.ts`, `.json`) +- HTML/CSS (`.html`, `.htm`, `.css`, `.scss`) +- Shell scripts (`.sh`, `.bash`, `.bat`, `.ps1`) +- SQL (`.sql`) + +### Documents +- PDF (`.pdf`) +- Text/Markdown (`.txt`, `.md`, `.markdown`) +- Office documents (`.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`) +- CSV (`.csv`) + +### Media Files +- Images (`.jpg`, `.png`, `.gif`, `.svg`, `.webp`) +- Videos (`.mp4`, `.mov`, `.avi`, `.webm`) +- Audio (`.mp3`, `.wav`, `.flac`, `.ogg`) + +### Archives +- Zip files (`.zip`, `.tar`, `.gz`, `.7z`, `.rar`) +- Package files (`.deb`, `.rpm`, `.dmg`, `.pkg`) + +### System Files +- Certificates (`.crt`, `.pem`, `.key`) +- Configuration files +- License files (LICENSE, README) + +## HTML Output Features + +### Responsive Design +- Mobile-friendly layout +- Collapsible columns on small screens +- Touch-friendly navigation + +### Dark Mode +- Automatic detection of system preference +- Clean dark color scheme +- Proper contrast ratios + +### File Information +- File sizes in human-readable format +- Last modified timestamps +- File type icons +- Sorting (directories first, then alphabetical) + +### Navigation +- Parent directory links +- Breadcrumb-style navigation +- Clickable file/directory entries + +## Project Structure + +``` +s3-genindex/ +├── cmd/s3-genindex/ # Main application +│ ├── main.go # CLI entry point +│ └── main_test.go # CLI tests +├── internal/indexgen/ # Core library +│ ├── indexgen.go # Main functionality +│ ├── indexgen_test.go # Unit tests +│ └── integration_test.go # Integration tests +├── docs/ # Documentation +├── Makefile # Build automation +├── README.md # Quick start guide +└── go.mod # Go module definition +``` + +## Development + +### Building + +```bash +make build # Build binary +make test # Run tests +make test-coverage # Run tests with coverage +make check # Run all checks (fmt, vet, lint, test) +``` + +### Testing + +The project includes comprehensive tests: + +- **Unit tests**: Test individual functions and utilities +- **Integration tests**: Test complete directory processing workflows +- **Template tests**: Verify HTML template generation +- **CLI tests**: Test command-line argument parsing + +Run tests with: + +```bash +make test # Basic test run +make test-coverage # With coverage report +go test -v ./... # Verbose output +go test -short ./... # Skip integration tests +``` + +### Code Quality + +```bash +make fmt # Format code +make vet # Vet for issues +make lint # Run golangci-lint (if installed) +make check # Run all quality checks +``` + +## Configuration + +No configuration files are needed. All options are provided via command-line arguments. + +### Environment Variables + +The tool respects standard Go environment variables: +- `GOOS` and `GOARCH` for cross-compilation +- `GOPATH` and `GOBIN` for installation paths + +## Comparison with Original + +This Go version provides the same functionality as the original Python script with these improvements: + +### Performance +- Faster execution, especially for large directory trees +- Lower memory usage +- Single binary deployment + +### Maintenance +- Comprehensive test suite +- Type safety +- Better error handling +- Structured codebase + +### Compatibility +- Identical HTML output +- Same command-line interface +- Cross-platform binary + +## Troubleshooting + +### Common Issues + +**Permission Errors** +```bash +# Ensure read permissions on target directory +chmod +r /path/to/directory +``` + +**Large Directories** +```bash +# Use verbose mode to monitor progress +s3-genindex -v /large/directory + +# Exclude unnecessary files +s3-genindex -x "(\.git|node_modules|__pycache__)" +``` + +**Memory Usage** +```bash +# Process directories individually for very large trees +for dir in */; do s3-genindex "$dir"; done +``` + +### Debug Mode + +Enable verbose output to see detailed processing: + +```bash +s3-genindex -v /path/to/debug +``` + +## License + +Licensed under the Apache License 2.0. See original Python script for full license text. + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Add tests for new functionality +4. Run `make check` to ensure code quality +5. Submit a pull request + +## Changelog + +### v1.0.0 +- Initial Go rewrite +- Complete feature parity with Python version +- Comprehensive test suite +- Modern Go project structure \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..92a7a7d --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.ipng.ch/ipng/s3-genindex + +go 1.23 diff --git a/internal/indexgen/indexgen.go b/internal/indexgen/indexgen.go new file mode 100644 index 0000000..340c1a3 --- /dev/null +++ b/internal/indexgen/indexgen.go @@ -0,0 +1,975 @@ +package indexgen + +import ( + "fmt" + "html/template" + "net/url" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" +) + +const ( + DefaultOutputFile = "index.html" +) + +var ExtensionTypes = map[string]string{ + "id_rsa": "cert", + "LICENSE": "license", + "README": "license", + ".jpg": "image", + ".jpeg": "image", + ".png": "image", + ".gif": "image", + ".webp": "image", + ".tiff": "image", + ".bmp": "image", + ".heif": "image", + ".heic": "image", + ".svg": "image", + ".mp4": "video", + ".mov": "video", + ".mpeg": "video", + ".avi": "video", + ".ogv": "video", + ".webm": "video", + ".mkv": "video", + ".vob": "video", + ".gifv": "video", + ".3gp": "video", + ".mp3": "audio", + ".m4a": "audio", + ".aac": "audio", + ".ogg": "audio", + ".flac": "audio", + ".wav": "audio", + ".wma": "audio", + ".midi": "audio", + ".cda": "audio", + ".aiff": "audio", + ".aif": "audio", + ".caf": "audio", + ".pdf": "pdf", + ".csv": "csv", + ".txt": "doc", + ".doc": "doc", + ".docx": "doc", + ".odt": "doc", + ".fodt": "doc", + ".rtf": "doc", + ".abw": "doc", + ".pages": "doc", + ".xls": "sheet", + ".xlsx": "sheet", + ".ods": "sheet", + ".fods": "sheet", + ".numbers": "sheet", + ".ppt": "ppt", + ".pptx": "ppt", + ".odp": "ppt", + ".fodp": "ppt", + ".zip": "archive", + ".gz": "archive", + ".xz": "archive", + ".tar": "archive", + ".7z": "archive", + ".rar": "archive", + ".zst": "archive", + ".bz2": "archive", + ".bzip": "archive", + ".arj": "archive", + ".z": "archive", + ".deb": "deb", + ".dpkg": "deb", + ".rpm": "dist", + ".exe": "dist", + ".flatpak": "dist", + ".appimage": "dist", + ".jar": "dist", + ".msi": "dist", + ".apk": "dist", + ".ps1": "ps1", + ".py": "py", + ".pyc": "py", + ".pyo": "py", + ".egg": "py", + ".sh": "sh", + ".bash": "sh", + ".com": "sh", + ".bat": "sh", + ".dll": "sh", + ".so": "sh", + ".dmg": "dmg", + ".iso": "iso", + ".img": "iso", + ".md": "md", + ".mdown": "md", + ".markdown": "md", + ".ttf": "font", + ".ttc": "font", + ".otf": "font", + ".woff": "font", + ".woff2": "font", + ".eof": "font", + ".apf": "font", + ".go": "go", + ".html": "html", + ".htm": "html", + ".php": "html", + ".php3": "html", + ".asp": "html", + ".aspx": "html", + ".css": "css", + ".scss": "css", + ".less": "css", + ".json": "json", + ".json5": "json", + ".jsonc": "json", + ".ts": "ts", + ".js": "js", + ".sql": "sql", + ".db": "db", + ".sqlite": "db", + ".mdb": "db", + ".odb": "db", + ".eml": "email", + ".email": "email", + ".mailbox": "email", + ".mbox": "email", + ".msg": "email", + ".crt": "cert", + ".pem": "cert", + ".x509": "cert", + ".cer": "cert", + ".der": "cert", + ".ca-bundle": "cert", + ".key": "keystore", + ".keystore": "keystore", + ".jks": "keystore", + ".p12": "keystore", + ".pfx": "keystore", + ".pub": "keystore", + "symlink": "symlink", + "generic": "generic", +} + +type Options struct { + TopDir string + Filter string + OutputFile string + DirAppend bool + Recursive bool + IncludeHidden bool + ExcludeRegex *regexp.Regexp + Verbose bool +} + +type FileEntry struct { + Name string + Path string + IsDir bool + IsSymlink bool + Size int64 + ModTime time.Time + IconType string + CSSClass string + SizePretty string + ModTimeISO string + ModTimeHuman string +} + +func ProcessDir(topDir string, opts *Options) error { + absPath, err := filepath.Abs(topDir) + if err != nil { + return fmt.Errorf("failed to get absolute path: %w", err) + } + + if opts.Verbose { + fmt.Printf("Traversing dir %s\n", absPath) + } + + indexPath := filepath.Join(absPath, opts.OutputFile) + + file, err := os.Create(indexPath) + if err != nil { + return fmt.Errorf("cannot create file %s: %w", indexPath, err) + } + defer file.Close() + + dirName := filepath.Base(absPath) + + entries, err := ReadDirEntries(absPath, opts) + if err != nil { + return fmt.Errorf("failed to read directory: %w", err) + } + + sort.Slice(entries, func(i, j int) bool { + if entries[i].IsDir != entries[j].IsDir { + return entries[i].IsDir + } + return entries[i].Name < entries[j].Name + }) + + templateData := struct { + DirName string + Entries []FileEntry + DirAppend bool + OutputFile string + }{ + DirName: dirName, + Entries: entries, + DirAppend: opts.DirAppend, + OutputFile: opts.OutputFile, + } + + err = GetHTMLTemplate().Execute(file, templateData) + if err != nil { + return fmt.Errorf("failed to execute template: %w", err) + } + + if opts.Recursive { + for _, entry := range entries { + if entry.IsDir && !entry.IsSymlink { + fullPath := filepath.Join(absPath, entry.Name) + err := ProcessDir(fullPath, opts) + if err != nil { + fmt.Printf("Error processing directory %s: %v\n", fullPath, err) + } + } + } + } + + return nil +} + +func ReadDirEntries(dirPath string, opts *Options) ([]FileEntry, error) { + files, err := os.ReadDir(dirPath) + if err != nil { + return nil, err + } + + var entries []FileEntry + + for _, file := range files { + fileName := file.Name() + + if strings.EqualFold(fileName, opts.OutputFile) { + continue + } + + if !opts.IncludeHidden && strings.HasPrefix(fileName, ".") { + continue + } + + if opts.ExcludeRegex != nil && opts.ExcludeRegex.MatchString(fileName) { + continue + } + + fullPath := filepath.Join(dirPath, fileName) + + info, err := file.Info() + if err != nil { + fmt.Printf("*** WARNING *** entry %s is not accessible! SKIPPING! Error: %v\n", fullPath, err) + continue + } + + if opts.Verbose { + fmt.Println(fullPath) + } + + entry := FileEntry{ + Name: fileName, + Path: fileName, + IsDir: file.IsDir(), + ModTime: info.ModTime(), + } + + entry.IsSymlink = info.Mode()&os.ModeSymlink != 0 + + if file.IsDir() && !entry.IsSymlink { + entry.Size = -1 + entry.SizePretty = "—" + entry.IconType = "folder" + entry.CSSClass = "folder_filled" + entry.Path = fileName + "/" + } else if file.IsDir() && entry.IsSymlink { + entry.Size = -1 + entry.SizePretty = "—" + entry.IconType = "folder-symlink" + fmt.Printf("dir-symlink %s\n", fullPath) + } else if !file.IsDir() && entry.IsSymlink { + entry.Size = info.Size() + entry.SizePretty = PrettySize(entry.Size) + entry.IconType = "symlink" + fmt.Printf("file-symlink %s\n", fullPath) + } else { + entry.Size = info.Size() + entry.SizePretty = PrettySize(entry.Size) + entry.IconType = GetIconType(fileName) + } + + if file.IsDir() && opts.DirAppend { + entry.Path += opts.OutputFile + } + + entry.ModTimeISO = entry.ModTime.Format(time.RFC3339) + entry.ModTimeHuman = entry.ModTime.Format(time.RFC822) + + entries = append(entries, entry) + } + + return entries, nil +} + +func GetIconType(fileName string) string { + ext := strings.ToLower(filepath.Ext(fileName)) + if iconType, exists := ExtensionTypes[ext]; exists { + return iconType + } + + if iconType, exists := ExtensionTypes[fileName]; exists { + return iconType + } + + return "generic" +} + +func PrettySize(bytes int64) string { + units := []struct { + factor int64 + suffix string + }{ + {1024 * 1024 * 1024 * 1024 * 1024, " PB"}, + {1024 * 1024 * 1024 * 1024, " TB"}, + {1024 * 1024 * 1024, " GB"}, + {1024 * 1024, " MB"}, + {1024, " KB"}, + {1, " byte"}, + } + + for _, unit := range units { + if bytes >= unit.factor { + amount := bytes / unit.factor + if unit.suffix == " byte" { + if amount == 1 { + return strconv.FormatInt(amount, 10) + " byte" + } + return strconv.FormatInt(amount, 10) + " bytes" + } + return strconv.FormatInt(amount, 10) + unit.suffix + } + } + return "0 bytes" +} + +func GetHTMLTemplate() *template.Template { + return template.Must(template.New("index").Funcs(template.FuncMap{ + "urlEscape": func(s string) string { + return url.QueryEscape(s) + }, + "safeHTML": func(s string) template.HTML { + return template.HTML(s) + }, + }).Parse(htmlTemplateString)) +} + +const htmlTemplateString = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

{{.DirName}}

+
+
+
+ + + + + + + + + + + + + + + + + + + {{range .Entries}} + + + + + + + + {{end}} + +
NameSize + Modified +
+ + + + +..
+ + + {{.Name}} + + {{safeHTML .SizePretty}}
+
+
+ +` \ No newline at end of file diff --git a/internal/indexgen/indexgen_test.go b/internal/indexgen/indexgen_test.go new file mode 100644 index 0000000..ccb9afb --- /dev/null +++ b/internal/indexgen/indexgen_test.go @@ -0,0 +1,385 @@ +package indexgen + +import ( + "bytes" + "os" + "path/filepath" + "regexp" + "testing" + "time" +) + +func TestPrettySize(t *testing.T) { + tests := []struct { + input int64 + expected string + }{ + {0, "0 bytes"}, + {1, "1 byte"}, + {2, "2 bytes"}, + {1023, "1023 bytes"}, + {1024, "1 KB"}, + {1536, "1 KB"}, + {2048, "2 KB"}, + {1024 * 1024, "1 MB"}, + {1024 * 1024 * 1024, "1 GB"}, + {1024 * 1024 * 1024 * 1024, "1 TB"}, + {1024 * 1024 * 1024 * 1024 * 1024, "1 PB"}, + } + + for _, tt := range tests { + result := PrettySize(tt.input) + if result != tt.expected { + t.Errorf("PrettySize(%d) = %s, want %s", tt.input, result, tt.expected) + } + } +} + +func TestGetIconType(t *testing.T) { + tests := []struct { + filename string + expected string + }{ + {"test.go", "go"}, + {"test.py", "py"}, + {"test.js", "js"}, + {"test.html", "html"}, + {"test.css", "css"}, + {"test.json", "json"}, + {"test.md", "md"}, + {"test.pdf", "pdf"}, + {"test.jpg", "image"}, + {"test.png", "image"}, + {"test.mp4", "video"}, + {"test.mp3", "audio"}, + {"test.zip", "archive"}, + {"test.txt", "doc"}, + {"README", "license"}, + {"LICENSE", "license"}, + {"unknown.ext", "generic"}, + {"noext", "generic"}, + } + + for _, tt := range tests { + result := GetIconType(tt.filename) + if result != tt.expected { + t.Errorf("GetIconType(%s) = %s, want %s", tt.filename, result, tt.expected) + } + } +} + +func TestGetIconTypeCaseInsensitive(t *testing.T) { + tests := []struct { + filename string + expected string + }{ + {"test.GO", "go"}, + {"test.Py", "py"}, + {"test.JPG", "image"}, + {"test.PNG", "image"}, + {"test.MP4", "video"}, + {"test.MP3", "audio"}, + } + + for _, tt := range tests { + result := GetIconType(tt.filename) + if result != tt.expected { + t.Errorf("GetIconType(%s) = %s, want %s", tt.filename, result, tt.expected) + } + } +} + +func TestHTMLTemplate(t *testing.T) { + tmpl := GetHTMLTemplate() + if tmpl == nil { + t.Fatal("GetHTMLTemplate() returned nil") + } + + // Test template execution with sample data + data := struct { + DirName string + Entries []FileEntry + DirAppend bool + OutputFile string + }{ + DirName: "test-dir", + Entries: []FileEntry{}, + DirAppend: false, + OutputFile: "index.html", + } + + var buf bytes.Buffer + err := tmpl.Execute(&buf, data) + if err != nil { + t.Fatalf("Template execution failed: %v", err) + } + + output := buf.String() + if !bytes.Contains([]byte(output), []byte("test-dir")) { + t.Error("Template output should contain directory name") + } + + if !bytes.Contains([]byte(output), []byte("")) { + t.Error("Template output should contain HTML doctype") + } +} + +func TestHTMLTemplateWithEntries(t *testing.T) { + tmpl := GetHTMLTemplate() + + entries := []FileEntry{ + { + Name: "test.go", + Path: "test.go", + IsDir: false, + IsSymlink: false, + Size: 1024, + ModTime: time.Now(), + IconType: "go", + CSSClass: "", + SizePretty: "1 KB", + ModTimeISO: "2023-01-01T12:00:00Z", + ModTimeHuman: "01 Jan 23 12:00 UTC", + }, + { + Name: "subfolder", + Path: "subfolder/", + IsDir: true, + IsSymlink: false, + Size: -1, + ModTime: time.Now(), + IconType: "folder", + CSSClass: "folder_filled", + SizePretty: "—", + ModTimeISO: "2023-01-01T12:00:00Z", + ModTimeHuman: "01 Jan 23 12:00 UTC", + }, + } + + data := struct { + DirName string + Entries []FileEntry + DirAppend bool + OutputFile string + }{ + DirName: "test-dir", + Entries: entries, + DirAppend: false, + OutputFile: "index.html", + } + + var buf bytes.Buffer + err := tmpl.Execute(&buf, data) + if err != nil { + t.Fatalf("Template execution with entries failed: %v", err) + } + + output := buf.String() + if !bytes.Contains([]byte(output), []byte("test.go")) { + t.Error("Template output should contain file name") + } + + if !bytes.Contains([]byte(output), []byte("subfolder")) { + t.Error("Template output should contain folder name") + } + + if !bytes.Contains([]byte(output), []byte("#go")) { + t.Error("Template output should contain Go icon reference") + } + + if !bytes.Contains([]byte(output), []byte("#folder")) { + t.Error("Template output should contain folder icon reference") + } +} + +func TestReadDirEntries(t *testing.T) { + // Create a temporary directory with test files + tempDir := t.TempDir() + + // Create test files + testFiles := []string{"test.go", "test.py", "README.md", ".hidden"} + for _, file := range testFiles { + f, err := os.Create(filepath.Join(tempDir, file)) + if err != nil { + t.Fatalf("Failed to create test file %s: %v", file, err) + } + _, err = f.WriteString("test content") + if err != nil { + t.Fatalf("Failed to write to file: %v", err) + } + f.Close() + } + + // Create a subdirectory + subDir := filepath.Join(tempDir, "subdir") + err := os.Mkdir(subDir, 0755) + if err != nil { + t.Fatalf("Failed to create subdirectory: %v", err) + } + + opts := &Options{ + OutputFile: "index.html", + IncludeHidden: false, + Verbose: false, + } + + entries, err := ReadDirEntries(tempDir, opts) + if err != nil { + t.Fatalf("ReadDirEntries failed: %v", err) + } + + // Should have 4 entries (3 visible files + 1 directory), .hidden should be excluded + expectedCount := 4 + if len(entries) != expectedCount { + t.Errorf("Expected %d entries, got %d", expectedCount, len(entries)) + } + + // Check that we have the expected files (ReadDirEntries doesn't sort, ProcessDir does) + foundDir := false + for _, entry := range entries { + if entry.IsDir && entry.Name == "subdir" { + foundDir = true + break + } + } + if !foundDir { + t.Error("Expected to find subdirectory 'subdir'") + } + + // Check that hidden file is excluded + for _, entry := range entries { + if entry.Name == ".hidden" { + t.Error("Hidden file should be excluded when IncludeHidden is false") + } + } +} + +func TestReadDirEntriesWithHidden(t *testing.T) { + tempDir := t.TempDir() + + // Create test files including hidden + testFiles := []string{"test.go", ".hidden"} + for _, file := range testFiles { + f, err := os.Create(filepath.Join(tempDir, file)) + if err != nil { + t.Fatalf("Failed to create test file %s: %v", file, err) + } + f.Close() + } + + opts := &Options{ + OutputFile: "index.html", + IncludeHidden: true, + Verbose: false, + } + + entries, err := ReadDirEntries(tempDir, opts) + if err != nil { + t.Fatalf("ReadDirEntries failed: %v", err) + } + + // Should include hidden file + found := false + for _, entry := range entries { + if entry.Name == ".hidden" { + found = true + break + } + } + if !found { + t.Error("Hidden file should be included when IncludeHidden is true") + } +} + +func TestReadDirEntriesWithRegexExclusion(t *testing.T) { + tempDir := t.TempDir() + + // Create test files + testFiles := []string{"test.go", "test.py", "build.log", "node_modules.txt"} + for _, file := range testFiles { + f, err := os.Create(filepath.Join(tempDir, file)) + if err != nil { + t.Fatalf("Failed to create test file %s: %v", file, err) + } + f.Close() + } + + regex := regexp.MustCompile("(build|node_modules)") + opts := &Options{ + OutputFile: "index.html", + IncludeHidden: false, + ExcludeRegex: regex, + Verbose: false, + } + + entries, err := ReadDirEntries(tempDir, opts) + if err != nil { + t.Fatalf("ReadDirEntries failed: %v", err) + } + + // Check that excluded files are not present + for _, entry := range entries { + if entry.Name == "build.log" || entry.Name == "node_modules.txt" { + t.Errorf("File %s should be excluded by regex", entry.Name) + } + } + + // Should have 2 entries (test.go, test.py) + if len(entries) != 2 { + t.Errorf("Expected 2 entries after regex exclusion, got %d", len(entries)) + } +} + +func TestFileEntryProperties(t *testing.T) { + tempDir := t.TempDir() + + // Create a test file with known content + testFile := filepath.Join(tempDir, "test.go") + content := "package main\nfunc main() {}\n" + err := os.WriteFile(testFile, []byte(content), 0644) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + opts := &Options{ + OutputFile: "index.html", + IncludeHidden: false, + Verbose: false, + } + + entries, err := ReadDirEntries(tempDir, opts) + if err != nil { + t.Fatalf("ReadDirEntries failed: %v", err) + } + + if len(entries) != 1 { + t.Fatalf("Expected 1 entry, got %d", len(entries)) + } + + entry := entries[0] + + // Verify properties + if entry.Name != "test.go" { + t.Errorf("Expected name 'test.go', got '%s'", entry.Name) + } + + if entry.IsDir { + t.Error("Expected file to not be a directory") + } + + if entry.IsSymlink { + t.Error("Expected file to not be a symlink") + } + + if entry.IconType != "go" { + t.Errorf("Expected icon type 'go', got '%s'", entry.IconType) + } + + if entry.Size != int64(len(content)) { + t.Errorf("Expected size %d, got %d", len(content), entry.Size) + } + + if entry.SizePretty != PrettySize(int64(len(content))) { + t.Errorf("Size pretty mismatch") + } +} \ No newline at end of file diff --git a/internal/indexgen/integration_test.go b/internal/indexgen/integration_test.go new file mode 100644 index 0000000..7fcfe38 --- /dev/null +++ b/internal/indexgen/integration_test.go @@ -0,0 +1,354 @@ +package indexgen + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +func TestProcessDirBasic(t *testing.T) { + tempDir := t.TempDir() + + // Create test files + testFiles := []string{"test.go", "test.py", "README.md"} + for _, file := range testFiles { + f, err := os.Create(filepath.Join(tempDir, file)) + if err != nil { + t.Fatalf("Failed to create test file %s: %v", file, err) + } + _, err = f.WriteString("test content") + if err != nil { + t.Fatalf("Failed to write to file: %v", err) + } + f.Close() + } + + opts := &Options{ + TopDir: tempDir, + OutputFile: "index.html", + IncludeHidden: false, + Verbose: false, + } + + err := ProcessDir(tempDir, opts) + if err != nil { + t.Fatalf("ProcessDir failed: %v", err) + } + + // Check that index.html was created + indexPath := filepath.Join(tempDir, "index.html") + if _, err := os.Stat(indexPath); os.IsNotExist(err) { + t.Fatal("index.html was not created") + } + + // Read and verify content + content, err := os.ReadFile(indexPath) + if err != nil { + t.Fatalf("Failed to read index.html: %v", err) + } + + htmlContent := string(content) + + // Check for HTML structure + if !strings.Contains(htmlContent, "") { + t.Error("index.html should contain HTML doctype") + } + + // Check for file entries + for _, file := range testFiles { + if !strings.Contains(htmlContent, file) { + t.Errorf("index.html should contain file %s", file) + } + } + + // Check for proper file icons + if !strings.Contains(htmlContent, "#go") { + t.Error("index.html should contain Go icon reference for .go files") + } + + if !strings.Contains(htmlContent, "#py") { + t.Error("index.html should contain Python icon reference for .py files") + } + + if !strings.Contains(htmlContent, "#md") { + t.Error("index.html should contain Markdown icon reference for .md files") + } +} + +func TestProcessDirRecursive(t *testing.T) { + tempDir := t.TempDir() + + // Create test files in root + f, err := os.Create(filepath.Join(tempDir, "root.txt")) + if err != nil { + t.Fatalf("Failed to create root file: %v", err) + } + f.Close() + + // Create subdirectory with files + subDir := filepath.Join(tempDir, "subdir") + err = os.Mkdir(subDir, 0755) + if err != nil { + t.Fatalf("Failed to create subdirectory: %v", err) + } + + subFile, err := os.Create(filepath.Join(subDir, "sub.txt")) + if err != nil { + t.Fatalf("Failed to create sub file: %v", err) + } + subFile.Close() + + // Create nested subdirectory + nestedDir := filepath.Join(subDir, "nested") + err = os.Mkdir(nestedDir, 0755) + if err != nil { + t.Fatalf("Failed to create nested directory: %v", err) + } + + nestedFile, err := os.Create(filepath.Join(nestedDir, "nested.txt")) + if err != nil { + t.Fatalf("Failed to create nested file: %v", err) + } + nestedFile.Close() + + opts := &Options{ + TopDir: tempDir, + OutputFile: "index.html", + Recursive: true, + IncludeHidden: false, + Verbose: false, + } + + err = ProcessDir(tempDir, opts) + if err != nil { + t.Fatalf("ProcessDir failed: %v", err) + } + + // Check that index.html files were created in each directory + indexPaths := []string{ + filepath.Join(tempDir, "index.html"), + filepath.Join(subDir, "index.html"), + filepath.Join(nestedDir, "index.html"), + } + + for _, indexPath := range indexPaths { + if _, err := os.Stat(indexPath); os.IsNotExist(err) { + t.Errorf("index.html was not created at %s", indexPath) + } + } + + // Check root index.html contains subdirectory + rootContent, err := os.ReadFile(filepath.Join(tempDir, "index.html")) + if err != nil { + t.Fatalf("Failed to read root index.html: %v", err) + } + + if !strings.Contains(string(rootContent), "subdir") { + t.Error("Root index.html should contain subdirectory") + } + + // Check sub index.html contains nested directory + subContent, err := os.ReadFile(filepath.Join(subDir, "index.html")) + if err != nil { + t.Fatalf("Failed to read sub index.html: %v", err) + } + + if !strings.Contains(string(subContent), "nested") { + t.Error("Sub index.html should contain nested directory") + } + + if !strings.Contains(string(subContent), "sub.txt") { + t.Error("Sub index.html should contain sub.txt file") + } +} + +func TestProcessDirWithExcludeRegex(t *testing.T) { + tempDir := t.TempDir() + + // Create test files + testFiles := []string{"include.go", "exclude.tmp", "node_modules.txt"} + for _, file := range testFiles { + f, err := os.Create(filepath.Join(tempDir, file)) + if err != nil { + t.Fatalf("Failed to create test file %s: %v", file, err) + } + f.Close() + } + + regex := regexp.MustCompile("(tmp|node_modules)") + opts := &Options{ + TopDir: tempDir, + OutputFile: "index.html", + ExcludeRegex: regex, + IncludeHidden: false, + Verbose: false, + } + + err := ProcessDir(tempDir, opts) + if err != nil { + t.Fatalf("ProcessDir failed: %v", err) + } + + // Read and verify content + content, err := os.ReadFile(filepath.Join(tempDir, "index.html")) + if err != nil { + t.Fatalf("Failed to read index.html: %v", err) + } + + htmlContent := string(content) + + // Check that included file is present + if !strings.Contains(htmlContent, "include.go") { + t.Error("index.html should contain include.go") + } + + // Check that excluded files are not present + if strings.Contains(htmlContent, "exclude.tmp") { + t.Error("index.html should not contain exclude.tmp (excluded by regex)") + } + + if strings.Contains(htmlContent, "node_modules.txt") { + t.Error("index.html should not contain node_modules.txt (excluded by regex)") + } +} + +func TestProcessDirWithDirAppend(t *testing.T) { + tempDir := t.TempDir() + + // Create a subdirectory + subDir := filepath.Join(tempDir, "subdir") + err := os.Mkdir(subDir, 0755) + if err != nil { + t.Fatalf("Failed to create subdirectory: %v", err) + } + + opts := &Options{ + TopDir: tempDir, + OutputFile: "index.html", + DirAppend: true, + IncludeHidden: false, + Verbose: false, + } + + err = ProcessDir(tempDir, opts) + if err != nil { + t.Fatalf("ProcessDir failed: %v", err) + } + + // Read and verify content + content, err := os.ReadFile(filepath.Join(tempDir, "index.html")) + if err != nil { + t.Fatalf("Failed to read index.html: %v", err) + } + + htmlContent := string(content) + + // Check that directory links include index.html (URL escaped) + if !strings.Contains(htmlContent, "subdir%2Findex.html") { + t.Errorf("Directory links should include index.html when DirAppend is true. Expected subdir%%2Findex.html in content") + } +} + +func TestProcessDirVerbose(t *testing.T) { + tempDir := t.TempDir() + + // Create test file + f, err := os.Create(filepath.Join(tempDir, "test.txt")) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + f.Close() + + opts := &Options{ + TopDir: tempDir, + OutputFile: "index.html", + Verbose: true, + IncludeHidden: false, + } + + err = ProcessDir(tempDir, opts) + if err != nil { + t.Fatalf("ProcessDir failed: %v", err) + } + + // Check that index.html was created + indexPath := filepath.Join(tempDir, "index.html") + if _, err := os.Stat(indexPath); os.IsNotExist(err) { + t.Fatal("index.html was not created") + } +} + +func TestProcessDirErrorHandling(t *testing.T) { + // Test with non-existent directory + opts := &Options{ + TopDir: "/non/existent/directory", + OutputFile: "index.html", + IncludeHidden: false, + Verbose: false, + } + + err := ProcessDir("/non/existent/directory", opts) + if err == nil { + t.Error("ProcessDir should fail with non-existent directory") + } +} + +func TestProcessDirWithSymlinks(t *testing.T) { + tempDir := t.TempDir() + + // Create a regular file + regularFile := filepath.Join(tempDir, "regular.txt") + f, err := os.Create(regularFile) + if err != nil { + t.Fatalf("Failed to create regular file: %v", err) + } + _, err = f.WriteString("content") + if err != nil { + t.Fatalf("Failed to write to file: %v", err) + } + f.Close() + + // Create a symlink to the file (skip on Windows) + symlinkFile := filepath.Join(tempDir, "symlink.txt") + err = os.Symlink(regularFile, symlinkFile) + if err != nil { + // Skip symlink tests on systems that don't support them + t.Skipf("Skipping symlink test: %v", err) + } + + opts := &Options{ + TopDir: tempDir, + OutputFile: "index.html", + IncludeHidden: false, + Verbose: false, + } + + err = ProcessDir(tempDir, opts) + if err != nil { + t.Fatalf("ProcessDir failed: %v", err) + } + + // Read and verify content + content, err := os.ReadFile(filepath.Join(tempDir, "index.html")) + if err != nil { + t.Fatalf("Failed to read index.html: %v", err) + } + + htmlContent := string(content) + + // Check that both regular file and symlink are present + if !strings.Contains(htmlContent, "regular.txt") { + t.Error("index.html should contain regular file") + } + + if !strings.Contains(htmlContent, "symlink.txt") { + t.Error("index.html should contain symlink file") + } + + // Check that symlink has appropriate icon + if !strings.Contains(htmlContent, "#symlink") { + t.Error("index.html should contain symlink icon for symlinked file") + } +} \ No newline at end of file