Files
go-jdenticon/cmd/jdenticon/batch.go
Kevin McIntyre f1544ef49c
Some checks failed
CI / Test (Go 1.24.x, ubuntu-latest) (push) Successful in 1m53s
CI / Code Quality (push) Failing after 26s
CI / Security Scan (push) Failing after 11s
CI / Test Coverage (push) Successful in 1m13s
CI / Benchmarks (push) Failing after 10m22s
CI / Build CLI (push) Failing after 8s
Benchmarks / Run Benchmarks (push) Failing after 10m13s
Release / Test (push) Successful in 55s
Release / Build (amd64, darwin, ) (push) Failing after 12s
Release / Build (amd64, linux, ) (push) Failing after 6s
Release / Build (amd64, windows, .exe) (push) Failing after 12s
Release / Build (arm64, darwin, ) (push) Failing after 12s
Release / Build (arm64, linux, ) (push) Failing after 12s
Release / Release (push) Has been skipped
CI / Test (Go 1.24.x, macos-latest) (push) Has been cancelled
CI / Test (Go 1.24.x, windows-latest) (push) Has been cancelled
chore: update module path to gitea.dockr.co/kev/go-jdenticon
Move hosting from GitHub to private Gitea instance.
2026-02-10 10:07:57 -05:00

333 lines
9.1 KiB
Go

package main
import (
"bufio"
"context"
"fmt"
"os"
"os/signal"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
"sync/atomic"
"syscall"
"github.com/mattn/go-isatty"
"github.com/schollz/progressbar/v3"
"github.com/spf13/cobra"
"gitea.dockr.co/kev/go-jdenticon/jdenticon"
)
const (
// Maximum filename length to avoid filesystem issues
maxFilenameLength = 200
)
var (
// Keep a-z, A-Z, 0-9, underscore, hyphen, and period. Replace everything else.
sanitizeRegex = regexp.MustCompile(`[^a-zA-Z0-9_.-]+`)
)
// batchJob represents a single identicon generation job
type batchJob struct {
value string
outputPath string
size int
}
// batchStats tracks processing statistics atomically
type batchStats struct {
processed int64
failed int64
}
// batchCmd represents the batch command
var batchCmd = &cobra.Command{
Use: "batch <input-file>",
Short: "Generate multiple identicons from a list using concurrent processing",
Long: `Generate multiple identicons from a list of values in a text file.
The input file should contain one value per line. Each value will be used to generate
an identicon saved to the output directory. The filename will be based on the input
value with special characters replaced.
Uses concurrent processing with a worker pool for optimal performance. The number
of concurrent workers can be controlled with the --concurrency flag.
Examples:
jdenticon batch users.txt --output-dir ./avatars
jdenticon batch emails.txt --output-dir ./avatars --format png --size 64
jdenticon batch large-list.txt --output-dir ./avatars --concurrency 8`,
Args: cobra.ExactArgs(1), // Validate argument count
RunE: func(cmd *cobra.Command, args []string) error {
return runConcurrentBatch(cmd, args)
},
}
func init() {
rootCmd.AddCommand(batchCmd)
// Define local flags specific to 'batch'
batchCmd.Flags().StringP("output-dir", "d", "", "Output directory for generated identicons (required)")
_ = batchCmd.MarkFlagRequired("output-dir")
// Concurrency control
batchCmd.Flags().IntP("concurrency", "c", runtime.NumCPU(),
fmt.Sprintf("Number of concurrent workers (default: %d)", runtime.NumCPU()))
}
// runConcurrentBatch executes the batch processing with concurrent workers
func runConcurrentBatch(cmd *cobra.Command, args []string) error {
inputFile := args[0]
outputDir, _ := cmd.Flags().GetString("output-dir")
concurrency, _ := cmd.Flags().GetInt("concurrency")
// Validate concurrency value
if concurrency <= 0 {
return fmt.Errorf("concurrency must be positive, got %d", concurrency)
}
// Get format from viper
format, err := getFormatFromViper()
if err != nil {
return err
}
// Populate library config from root persistent flags
config, size, err := populateConfigFromFlags()
if err != nil {
return err
}
// Create generator with custom config and larger cache for concurrent workload
cacheSize := generatorCacheSize * concurrency // Scale cache with worker count
generator, err := jdenticon.NewGeneratorWithConfig(config, cacheSize)
if err != nil {
return fmt.Errorf("failed to create generator: %w", err)
}
// Create output directory if it doesn't exist
// #nosec G301 -- 0755 is standard for directories; CLI tool needs world-readable output
if err = os.MkdirAll(outputDir, 0755); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
// Set up graceful shutdown context
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// Process the file and collect jobs
jobs, total, err := prepareJobs(inputFile, outputDir, format, size)
if err != nil {
return err
}
if total == 0 {
fmt.Fprintf(os.Stderr, "No valid entries found in input file\n")
return nil
}
// Initialize progress reporting
showProgress := isTTY(os.Stderr)
var bar *progressbar.ProgressBar
if showProgress {
bar = createProgressBar(total)
}
// Execute concurrent processing
stats, err := processConcurrentJobs(ctx, jobs, generator, format, concurrency, bar)
if err != nil {
return err
}
// Final status message
processed := atomic.LoadInt64(&stats.processed)
failed := atomic.LoadInt64(&stats.failed)
if showProgress {
fmt.Fprintf(os.Stderr, "\nBatch processing complete: %d succeeded, %d failed\n", processed, failed)
} else {
fmt.Fprintf(os.Stderr, "Batch processing complete: %d succeeded, %d failed\n", processed, failed)
}
if failed > 0 {
return fmt.Errorf("some identicons failed to generate")
}
return nil
}
// prepareJobs reads the input file and creates a slice of jobs
func prepareJobs(inputFile, outputDir string, format FormatFlag, size int) ([]batchJob, int, error) {
// #nosec G304 -- inputFile is provided via CLI flag, validated by cobra
file, err := os.Open(inputFile)
if err != nil {
return nil, 0, fmt.Errorf("failed to open input file: %w", err)
}
defer file.Close()
var jobs []batchJob
scanner := bufio.NewScanner(file)
for scanner.Scan() {
value := strings.TrimSpace(scanner.Text())
if value == "" {
continue // Skip empty lines
}
// Generate filename from value (sanitize for filesystem)
filename := sanitizeFilename(value)
extension := ".svg"
if format == FormatPNG {
extension = ".png"
}
outputPath := filepath.Join(outputDir, filename+extension)
jobs = append(jobs, batchJob{
value: value,
outputPath: outputPath,
size: size,
})
}
if err := scanner.Err(); err != nil {
return nil, 0, fmt.Errorf("error reading input file: %w", err)
}
return jobs, len(jobs), nil
}
// createProgressBar creates a progress bar for the batch processing
func createProgressBar(total int) *progressbar.ProgressBar {
return progressbar.NewOptions(total,
progressbar.OptionSetWriter(os.Stderr),
progressbar.OptionSetDescription("Processing identicons..."),
progressbar.OptionEnableColorCodes(true),
progressbar.OptionShowCount(),
progressbar.OptionSetWidth(15),
progressbar.OptionSetTheme(progressbar.Theme{
Saucer: "[green]=[reset]",
SaucerHead: "[green]>[reset]",
SaucerPadding: " ",
BarStart: "[",
BarEnd: "]",
}),
)
}
// processConcurrentJobs executes jobs using a worker pool pattern
func processConcurrentJobs(ctx context.Context, jobs []batchJob, generator *jdenticon.Generator,
format FormatFlag, concurrency int, bar *progressbar.ProgressBar) (*batchStats, error) {
stats := &batchStats{}
jobChan := make(chan batchJob, len(jobs))
var wg sync.WaitGroup
// Start worker goroutines
for i := 0; i < concurrency; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
batchWorker(ctx, workerID, jobChan, generator, format, stats, bar)
}(i)
}
// Send jobs to workers
go func() {
defer close(jobChan)
for _, job := range jobs {
select {
case jobChan <- job:
case <-ctx.Done():
return
}
}
}()
// Wait for all workers to complete
wg.Wait()
// Check if processing was interrupted
if ctx.Err() != nil {
return stats, fmt.Errorf("processing interrupted: %w", ctx.Err())
}
return stats, nil
}
// batchWorker processes jobs from the job channel
func batchWorker(ctx context.Context, workerID int, jobs <-chan batchJob, generator *jdenticon.Generator,
format FormatFlag, stats *batchStats, bar *progressbar.ProgressBar) {
for {
select {
case job, ok := <-jobs:
if !ok {
// Channel closed, no more jobs
return
}
// Process the job with context for cancellation support
if err := processJob(ctx, job, generator, format); err != nil {
fmt.Fprintf(os.Stderr, "Worker %d failed to process %q: %v\n", workerID, job.value, err)
atomic.AddInt64(&stats.failed, 1)
} else {
atomic.AddInt64(&stats.processed, 1)
}
// Update progress bar if available
if bar != nil {
_ = bar.Add(1)
}
case <-ctx.Done():
// Shutdown signal received
return
}
}
}
// processJob handles a single identicon generation job
func processJob(ctx context.Context, job batchJob, generator *jdenticon.Generator, format FormatFlag) error {
// Generate identicon with context for cancellation support
icon, err := generator.Generate(ctx, job.value, job.size)
if err != nil {
return fmt.Errorf("failed to generate identicon: %w", err)
}
// Generate output based on format
result, err := renderIcon(icon, format)
if err != nil {
return fmt.Errorf("failed to render %s: %w", format, err)
}
// Write file
// #nosec G306 -- 0644 is appropriate for generated image files (world-readable)
if err := os.WriteFile(job.outputPath, result, 0644); err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
return nil
}
// sanitizeFilename converts a value to a safe filename using a whitelist approach
func sanitizeFilename(value string) string {
// Replace @ separately for readability if desired
filename := strings.ReplaceAll(value, "@", "_at_")
// Replace all other invalid characters with a single underscore
filename = sanitizeRegex.ReplaceAllString(filename, "_")
// Limit length to avoid filesystem issues
if len(filename) > maxFilenameLength {
filename = filename[:maxFilenameLength]
}
return filename
}
// isTTY checks if the given file descriptor is a terminal
func isTTY(f *os.File) bool {
return isatty.IsTerminal(f.Fd())
}