diff --git a/.gitignore b/.gitignore index e86019f..9bf215f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ -effects \ No newline at end of file +effects +src/bin/ +src/coverage.out +src/logs/ diff --git a/README.md b/README.md index b1befbf..d27ce3c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# ML4W Wallpaper Collection +# Wallpaper Collection -My personal wallpaper collection that fits best to tiling window managers. +Wallpaper collection that fits best to high res 16:9/16:10 aspect ratio's. ![image](https://github.com/user-attachments/assets/9f94272d-7278-4599-801c-8b104ca746c8) @@ -10,11 +10,9 @@ Clone the directory from your home directory. ``` cd ~/Pictures # You can also choose a different location -git clone --depth=1 https://github.com/mylinuxforwork/wallpaper.git +git clone --depth=1 https://github.com/pearsonc/wallpaper.git cd wallpaper/ ``` -If you are using the ML4W Dotfiles for Hyprland, you can select the the new wallpaper folder with Waypaper. - ## Update You can update the wallpapers with diff --git a/cyberpunk-motorcycle.jpg b/cyberpunk-motorcycle.jpg new file mode 100644 index 0000000..2607de3 Binary files /dev/null and b/cyberpunk-motorcycle.jpg differ diff --git a/forest-night-full-moon.jpg b/forest-night-full-moon.jpg new file mode 100644 index 0000000..577f2a8 Binary files /dev/null and b/forest-night-full-moon.jpg differ diff --git a/lonely_street_lamp.jpg b/lonely_street_lamp.jpg new file mode 100644 index 0000000..bbd9e00 Binary files /dev/null and b/lonely_street_lamp.jpg differ diff --git a/miracleOS_light.jpg b/miracleOS_light.jpg deleted file mode 100644 index 1ac4ccc..0000000 Binary files a/miracleOS_light.jpg and /dev/null differ diff --git a/Fantasy-Autumn.png b/not 16-9 or 16-10 Aspect Ratio/Fantasy-Autumn.png similarity index 100% rename from Fantasy-Autumn.png rename to not 16-9 or 16-10 Aspect Ratio/Fantasy-Autumn.png diff --git a/adwaita-l.jpg b/not 16-9 or 16-10 Aspect Ratio/adwaita-l.jpg similarity index 100% rename from adwaita-l.jpg rename to not 16-9 or 16-10 Aspect Ratio/adwaita-l.jpg diff --git a/amazing-nature-mountain.jpg b/not 16-9 or 16-10 Aspect Ratio/amazing-nature-mountain.jpg similarity index 100% rename from amazing-nature-mountain.jpg rename to not 16-9 or 16-10 Aspect Ratio/amazing-nature-mountain.jpg diff --git a/anime2.jpg b/not 16-9 or 16-10 Aspect Ratio/anime2.jpg similarity index 100% rename from anime2.jpg rename to not 16-9 or 16-10 Aspect Ratio/anime2.jpg diff --git a/apple-dark.jpg b/not 16-9 or 16-10 Aspect Ratio/apple-dark.jpg similarity index 100% rename from apple-dark.jpg rename to not 16-9 or 16-10 Aspect Ratio/apple-dark.jpg diff --git a/apple-light.jpg b/not 16-9 or 16-10 Aspect Ratio/apple-light.jpg similarity index 100% rename from apple-light.jpg rename to not 16-9 or 16-10 Aspect Ratio/apple-light.jpg diff --git a/apple-waves1.jpg b/not 16-9 or 16-10 Aspect Ratio/apple-waves1.jpg similarity index 100% rename from apple-waves1.jpg rename to not 16-9 or 16-10 Aspect Ratio/apple-waves1.jpg diff --git a/arch1.png b/not 16-9 or 16-10 Aspect Ratio/arch1.png similarity index 100% rename from arch1.png rename to not 16-9 or 16-10 Aspect Ratio/arch1.png diff --git a/calder-moore-aliencrack-01-copy.jpg b/not 16-9 or 16-10 Aspect Ratio/calder-moore-aliencrack-01-copy.jpg similarity index 100% rename from calder-moore-aliencrack-01-copy.jpg rename to not 16-9 or 16-10 Aspect Ratio/calder-moore-aliencrack-01-copy.jpg diff --git a/city-from-above.jpg b/not 16-9 or 16-10 Aspect Ratio/city-from-above.jpg similarity index 100% rename from city-from-above.jpg rename to not 16-9 or 16-10 Aspect Ratio/city-from-above.jpg diff --git a/copper_mountain.jpg b/not 16-9 or 16-10 Aspect Ratio/copper_mountain.jpg similarity index 100% rename from copper_mountain.jpg rename to not 16-9 or 16-10 Aspect Ratio/copper_mountain.jpg diff --git a/cyberpunk2-drive.jpg b/not 16-9 or 16-10 Aspect Ratio/cyberpunk2-drive.jpg similarity index 100% rename from cyberpunk2-drive.jpg rename to not 16-9 or 16-10 Aspect Ratio/cyberpunk2-drive.jpg diff --git a/deer_and_sunset.jpg b/not 16-9 or 16-10 Aspect Ratio/deer_and_sunset.jpg similarity index 100% rename from deer_and_sunset.jpg rename to not 16-9 or 16-10 Aspect Ratio/deer_and_sunset.jpg diff --git a/deer_in_pine_forest.jpg b/not 16-9 or 16-10 Aspect Ratio/deer_in_pine_forest.jpg similarity index 100% rename from deer_in_pine_forest.jpg rename to not 16-9 or 16-10 Aspect Ratio/deer_in_pine_forest.jpg diff --git a/escape_velocity.jpg b/not 16-9 or 16-10 Aspect Ratio/escape_velocity.jpg similarity index 100% rename from escape_velocity.jpg rename to not 16-9 or 16-10 Aspect Ratio/escape_velocity.jpg diff --git a/explorer-robot.png b/not 16-9 or 16-10 Aspect Ratio/explorer-robot.png similarity index 100% rename from explorer-robot.png rename to not 16-9 or 16-10 Aspect Ratio/explorer-robot.png diff --git a/flowers1.png b/not 16-9 or 16-10 Aspect Ratio/flowers1.png similarity index 100% rename from flowers1.png rename to not 16-9 or 16-10 Aspect Ratio/flowers1.png diff --git a/forest-landscape.jpg b/not 16-9 or 16-10 Aspect Ratio/forest-landscape.jpg similarity index 100% rename from forest-landscape.jpg rename to not 16-9 or 16-10 Aspect Ratio/forest-landscape.jpg diff --git a/gdtography.jpg b/not 16-9 or 16-10 Aspect Ratio/gdtography.jpg similarity index 100% rename from gdtography.jpg rename to not 16-9 or 16-10 Aspect Ratio/gdtography.jpg diff --git a/geometric-horizons.png b/not 16-9 or 16-10 Aspect Ratio/geometric-horizons.png similarity index 100% rename from geometric-horizons.png rename to not 16-9 or 16-10 Aspect Ratio/geometric-horizons.png diff --git a/glowing-green-dew.jpg b/not 16-9 or 16-10 Aspect Ratio/glowing-green-dew.jpg similarity index 100% rename from glowing-green-dew.jpg rename to not 16-9 or 16-10 Aspect Ratio/glowing-green-dew.jpg diff --git a/groot_1.jpg b/not 16-9 or 16-10 Aspect Ratio/groot_1.jpg similarity index 100% rename from groot_1.jpg rename to not 16-9 or 16-10 Aspect Ratio/groot_1.jpg diff --git a/kath.jpg b/not 16-9 or 16-10 Aspect Ratio/kath.jpg similarity index 100% rename from kath.jpg rename to not 16-9 or 16-10 Aspect Ratio/kath.jpg diff --git a/landscape1.png b/not 16-9 or 16-10 Aspect Ratio/landscape1.png similarity index 100% rename from landscape1.png rename to not 16-9 or 16-10 Aspect Ratio/landscape1.png diff --git a/landscape4-whisper.png b/not 16-9 or 16-10 Aspect Ratio/landscape4-whisper.png similarity index 100% rename from landscape4-whisper.png rename to not 16-9 or 16-10 Aspect Ratio/landscape4-whisper.png diff --git a/lofoten1.jpg b/not 16-9 or 16-10 Aspect Ratio/lofoten1.jpg similarity index 100% rename from lofoten1.jpg rename to not 16-9 or 16-10 Aspect Ratio/lofoten1.jpg diff --git a/minimal-beton-building.jpg b/not 16-9 or 16-10 Aspect Ratio/minimal-beton-building.jpg similarity index 100% rename from minimal-beton-building.jpg rename to not 16-9 or 16-10 Aspect Ratio/minimal-beton-building.jpg diff --git a/mountain-lake.jpg b/not 16-9 or 16-10 Aspect Ratio/mountain-lake.jpg similarity index 100% rename from mountain-lake.jpg rename to not 16-9 or 16-10 Aspect Ratio/mountain-lake.jpg diff --git a/mountain-winter3.jpg b/not 16-9 or 16-10 Aspect Ratio/mountain-winter3.jpg similarity index 100% rename from mountain-winter3.jpg rename to not 16-9 or 16-10 Aspect Ratio/mountain-winter3.jpg diff --git a/mountain-with-snow.jpg b/not 16-9 or 16-10 Aspect Ratio/mountain-with-snow.jpg similarity index 100% rename from mountain-with-snow.jpg rename to not 16-9 or 16-10 Aspect Ratio/mountain-with-snow.jpg diff --git a/mountain.jpg b/not 16-9 or 16-10 Aspect Ratio/mountain.jpg similarity index 100% rename from mountain.jpg rename to not 16-9 or 16-10 Aspect Ratio/mountain.jpg diff --git a/mountain_view.jpg b/not 16-9 or 16-10 Aspect Ratio/mountain_view.jpg similarity index 100% rename from mountain_view.jpg rename to not 16-9 or 16-10 Aspect Ratio/mountain_view.jpg diff --git a/mountains-with-sky.jpg b/not 16-9 or 16-10 Aspect Ratio/mountains-with-sky.jpg similarity index 100% rename from mountains-with-sky.jpg rename to not 16-9 or 16-10 Aspect Ratio/mountains-with-sky.jpg diff --git a/natures-mountain-waters.jpg b/not 16-9 or 16-10 Aspect Ratio/natures-mountain-waters.jpg similarity index 100% rename from natures-mountain-waters.jpg rename to not 16-9 or 16-10 Aspect Ratio/natures-mountain-waters.jpg diff --git a/neon_car.jpg b/not 16-9 or 16-10 Aspect Ratio/neon_car.jpg similarity index 100% rename from neon_car.jpg rename to not 16-9 or 16-10 Aspect Ratio/neon_car.jpg diff --git a/neon_office.jpg b/not 16-9 or 16-10 Aspect Ratio/neon_office.jpg similarity index 100% rename from neon_office.jpg rename to not 16-9 or 16-10 Aspect Ratio/neon_office.jpg diff --git a/new-worlds.png b/not 16-9 or 16-10 Aspect Ratio/new-worlds.png similarity index 100% rename from new-worlds.png rename to not 16-9 or 16-10 Aspect Ratio/new-worlds.png diff --git a/nordwall3.jpg b/not 16-9 or 16-10 Aspect Ratio/nordwall3.jpg similarity index 100% rename from nordwall3.jpg rename to not 16-9 or 16-10 Aspect Ratio/nordwall3.jpg diff --git a/orange-art.jpg b/not 16-9 or 16-10 Aspect Ratio/orange-art.jpg similarity index 100% rename from orange-art.jpg rename to not 16-9 or 16-10 Aspect Ratio/orange-art.jpg diff --git a/pexels-robert-clark.jpg b/not 16-9 or 16-10 Aspect Ratio/pexels-robert-clark.jpg similarity index 100% rename from pexels-robert-clark.jpg rename to not 16-9 or 16-10 Aspect Ratio/pexels-robert-clark.jpg diff --git a/red-and-blue-pillars.jpg b/not 16-9 or 16-10 Aspect Ratio/red-and-blue-pillars.jpg similarity index 100% rename from red-and-blue-pillars.jpg rename to not 16-9 or 16-10 Aspect Ratio/red-and-blue-pillars.jpg diff --git a/retro-truck.png b/not 16-9 or 16-10 Aspect Ratio/retro-truck.png similarity index 100% rename from retro-truck.png rename to not 16-9 or 16-10 Aspect Ratio/retro-truck.png diff --git a/rocket_launch.jpg b/not 16-9 or 16-10 Aspect Ratio/rocket_launch.jpg similarity index 100% rename from rocket_launch.jpg rename to not 16-9 or 16-10 Aspect Ratio/rocket_launch.jpg diff --git a/room-dark.png b/not 16-9 or 16-10 Aspect Ratio/room-dark.png similarity index 100% rename from room-dark.png rename to not 16-9 or 16-10 Aspect Ratio/room-dark.png diff --git a/snowy-peace.png b/not 16-9 or 16-10 Aspect Ratio/snowy-peace.png similarity index 100% rename from snowy-peace.png rename to not 16-9 or 16-10 Aspect Ratio/snowy-peace.png diff --git a/spirited-away1.jpg b/not 16-9 or 16-10 Aspect Ratio/spirited-away1.jpg similarity index 100% rename from spirited-away1.jpg rename to not 16-9 or 16-10 Aspect Ratio/spirited-away1.jpg diff --git a/street1.jpg b/not 16-9 or 16-10 Aspect Ratio/street1.jpg similarity index 100% rename from street1.jpg rename to not 16-9 or 16-10 Aspect Ratio/street1.jpg diff --git a/street2.gif b/not 16-9 or 16-10 Aspect Ratio/street2.gif similarity index 100% rename from street2.gif rename to not 16-9 or 16-10 Aspect Ratio/street2.gif diff --git a/sunset-drive-forest.jpg b/not 16-9 or 16-10 Aspect Ratio/sunset-drive-forest.jpg similarity index 100% rename from sunset-drive-forest.jpg rename to not 16-9 or 16-10 Aspect Ratio/sunset-drive-forest.jpg diff --git a/suzume1.jpg b/not 16-9 or 16-10 Aspect Ratio/suzume1.jpg similarity index 100% rename from suzume1.jpg rename to not 16-9 or 16-10 Aspect Ratio/suzume1.jpg diff --git a/the-sphere.png b/not 16-9 or 16-10 Aspect Ratio/the-sphere.png similarity index 100% rename from the-sphere.png rename to not 16-9 or 16-10 Aspect Ratio/the-sphere.png diff --git a/tree.jpg b/not 16-9 or 16-10 Aspect Ratio/tree.jpg similarity index 100% rename from tree.jpg rename to not 16-9 or 16-10 Aspect Ratio/tree.jpg diff --git a/vintage-ascent.jpg b/not 16-9 or 16-10 Aspect Ratio/vintage-ascent.jpg similarity index 100% rename from vintage-ascent.jpg rename to not 16-9 or 16-10 Aspect Ratio/vintage-ascent.jpg diff --git a/wallhaven1.jpg b/not 16-9 or 16-10 Aspect Ratio/wallhaven1.jpg similarity index 100% rename from wallhaven1.jpg rename to not 16-9 or 16-10 Aspect Ratio/wallhaven1.jpg diff --git a/wallhaven4.jpg b/not 16-9 or 16-10 Aspect Ratio/wallhaven4.jpg similarity index 100% rename from wallhaven4.jpg rename to not 16-9 or 16-10 Aspect Ratio/wallhaven4.jpg diff --git a/plasmawaves.png b/plasmawaves.png deleted file mode 100644 index b36a68a..0000000 Binary files a/plasmawaves.png and /dev/null differ diff --git a/river-stream-forest.jpg b/river-stream-forest.jpg new file mode 100644 index 0000000..e1b0321 Binary files /dev/null and b/river-stream-forest.jpg differ diff --git a/src/Makefile b/src/Makefile new file mode 100644 index 0000000..31b3ebd --- /dev/null +++ b/src/Makefile @@ -0,0 +1,35 @@ +BINARY := ne-image-sorter +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +LDFLAGS := -X main.version=$(VERSION) + +.PHONY: all build run test cover vet fmt lint clean install + +all: vet test build + +build: + go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/$(BINARY) + +run: build + ./bin/$(BINARY) + +test: + go test ./... + +cover: + go test -coverprofile=coverage.out ./... + go tool cover -func=coverage.out | tail -1 + +vet: + go vet ./... + +fmt: + gofmt -l -w . + +lint: vet + gofmt -l . | tee /dev/stderr | test ! -s /dev/stdin + +install: build + install -Dm755 bin/$(BINARY) $(HOME)/.local/bin/$(BINARY) + +clean: + rm -rf bin coverage.out diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..5ac41fd --- /dev/null +++ b/src/README.md @@ -0,0 +1,85 @@ +# ne-image-sorter + +Sorts a folder of images by aspect ratio and resolution, moving everything a +configurable policy rejects into a second folder. Built for this wallpaper +collection, but the policy is general. + +## Build + +``` +cd src +make build # bin/ne-image-sorter +make install # ~/.local/bin/ne-image-sorter +``` + +## Run + +``` +ne-image-sorter # terminal interface +ne-image-sorter --sort --dry-run # report, move nothing +ne-image-sorter --sort # move, no interface +ne-image-sorter --source ~/pics --dest ~/pics/x # override the saved folders +``` + +| Flag | Meaning | +|------|---------| +| `--sort` | Run headless and exit, for scripts and cron | +| `--dry-run` | With `--sort`, report what would move without moving it | +| `--source`, `--dest` | Override the saved directories for one run | +| `--config` | Configuration file, default `~/.config/ne-image-sorter/config.json` | +| `--log-dir` | Log directory, default `~/.cache/ne-image-sorter` | +| `--version` | Print the version and exit | + +## How the policy works + +Rules are checked top to bottom against each image. **The first rule that +matches decides**, and anything reaching the bottom takes the default. That +ordering is what lets one narrow move rule sit above broad keep rules. + +The shipped policy is the one this collection was sorted with: + +``` +1. move if height <= 1080 +2. keep if aspect == 16:9 (+/-1%) +3. keep if aspect == 16:10 (+/-1%) + otherwise move +``` + +A rule tests `aspect`, `width`, or `height` with `==`, `!=`, `<`, `<=`, `>`, or +`>=`. Tolerance is a percentage and applies to `==` and `!=` only, which is what +makes `==` usable against real files: a 2912x1632 wallpaper is 0.37% off 16:9 +and a strict comparison would reject it. Aspect values accept `16:9`, `16/10`, +or a decimal such as `1.7778`. + +The default policy reproduces the collection's own 2025-12-17 hand sort on 34 of +its 35 moves and 115 of its 120 keeps. `TestDefaultPolicyReproducesTheManualSort` +pins that, so changing the shipped defaults breaks the build rather than +silently re-filing the library. + +## Keys + +`↑`/`↓` move, `enter` confirms, `esc` goes back, `q` or `ctrl+c` quits. Those +mean the same thing on every screen. Nothing moves until the preview is +confirmed with `y`, and an existing file is never overwritten: a name clash is +suffixed `-1`, `-2` and so on. + +In **Rules**: `a` adds, `d` deletes, `J`/`K` reorder, `t` toggles the default, +`r` restores the shipped policy. + +## Layout + +``` +cmd/ne-image-sorter/ entry point, flags, headless mode +internal/domain/ Image, Rule, Policy, Config. No I/O, no dependencies +internal/repository/ Images and Config interfaces, plus filesystem and JSON +internal/sorter/ Plan then Apply, over the repository interfaces +internal/tui/ Bubbletea screens +internal/logging/ zerolog to a file +``` + +The repository interfaces are what let the service and every screen be tested +against in-memory fakes. Only `internal/repository` touches the filesystem. + +Logs go to a file and never to the console, which is both the house rule and a +hard requirement here: a terminal interface redraws continuously, so a stray +console write corrupts the display. diff --git a/src/cmd/ne-image-sorter/main.go b/src/cmd/ne-image-sorter/main.go new file mode 100644 index 0000000..e43f6a4 --- /dev/null +++ b/src/cmd/ne-image-sorter/main.go @@ -0,0 +1,134 @@ +// Command ne-image-sorter sorts a directory of images by aspect ratio and +// resolution, moving everything a configurable policy rejects into a second +// directory. It runs as a terminal interface by default, and headless with +// --sort for scripted use. +package main + +import ( + "flag" + "fmt" + "io" + "os" + "path/filepath" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/nerdexecutive/ne-image-sorter/internal/domain" + "github.com/nerdexecutive/ne-image-sorter/internal/logging" + "github.com/nerdexecutive/ne-image-sorter/internal/repository" + "github.com/nerdexecutive/ne-image-sorter/internal/sorter" + "github.com/nerdexecutive/ne-image-sorter/internal/tui" +) + +// version is overridden at build time with -ldflags "-X main.version=...". +var version = "dev" + +func main() { + if err := run(os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, "ne-image-sorter:", err) + os.Exit(1) + } +} + +// run wires the application together. Result output goes to out rather than +// to the process stdout directly, which keeps the headless path testable and +// leaves the log file as the only place this tool writes diagnostics. +func run(out io.Writer) error { + var ( + showVersion = flag.Bool("version", false, "print the version and exit") + configPath = flag.String("config", defaultConfigPath(), "path to the configuration file") + logDir = flag.String("log-dir", defaultLogDir(), "directory to write the log file into") + source = flag.String("source", "", "override the configured source directory") + dest = flag.String("dest", "", "override the configured destination directory") + dryRun = flag.Bool("dry-run", false, "with --sort, report what would move without moving it") + headless = flag.Bool("sort", false, "sort without the interface and exit") + ) + flag.Parse() + + if *showVersion { + fmt.Fprintln(out, "ne-image-sorter", version) + return nil + } + + log, logFile, err := logging.Open(*logDir) + if err != nil { + return fmt.Errorf("start ne-image-sorter: %w", err) + } + defer logFile.Close() + + configs := repository.NewJSONConfig(*configPath) + cfg, err := configs.Load() + if err != nil { + return fmt.Errorf("start ne-image-sorter: %w", err) + } + if *source != "" { + cfg.SourceDir = *source + } + if *dest != "" { + cfg.DestDir = *dest + } + + svc := sorter.New(repository.NewFileImages(), log) + if *headless { + return sortHeadless(out, svc, cfg, *dryRun) + } + + program := tea.NewProgram(tui.NewApp(cfg, configs, svc, log), tea.WithAltScreen()) + if _, err := program.Run(); err != nil { + log.Error().Err(err).Msg("interface exited with an error") + return fmt.Errorf("run interface: %w", err) + } + return nil +} + +// sortHeadless runs one sort without the interface, for scripts and cron. +func sortHeadless(out io.Writer, svc *sorter.Service, cfg domain.Config, dryRun bool) error { + plan, err := svc.Plan(cfg) + if err != nil { + return fmt.Errorf("headless sort: %w", err) + } + + for _, d := range plan.Moves() { + fmt.Fprintf(out, "move %-40s %-11s %s\n", d.Image.Name, d.Image.Resolution(), d.Reason) + } + fmt.Fprintf(out, "\n%d to move, %d to keep, %d scanned\n", + plan.MoveCount, plan.KeepCount, len(plan.Decisions)) + + if dryRun { + fmt.Fprintln(out, "dry run: nothing was moved") + return nil + } + + report, err := svc.Apply(cfg, plan) + if err != nil { + return fmt.Errorf("headless sort: %w", err) + } + fmt.Fprintf(out, "moved %d, failed %d\n", report.Moved, report.Failed) + for _, e := range report.Errors { + fmt.Fprintln(out, " failed:", e) + } + if report.Failed > 0 { + return fmt.Errorf("headless sort: %d image(s) could not be moved", report.Failed) + } + return nil +} + +// defaultConfigPath is the per-user configuration location, falling back to +// the working directory when the user config directory is unavailable. +func defaultConfigPath() string { + dir, err := os.UserConfigDir() + if err != nil { + return "ne-image-sorter.json" + } + return filepath.Join(dir, "ne-image-sorter", "config.json") +} + +// defaultLogDir is the per-user log location, falling back to a local logs +// directory. +func defaultLogDir() string { + dir, err := os.UserCacheDir() + if err != nil { + return "logs" + } + return filepath.Join(dir, "ne-image-sorter") +} diff --git a/src/cmd/ne-image-sorter/main_test.go b/src/cmd/ne-image-sorter/main_test.go new file mode 100644 index 0000000..fea1313 --- /dev/null +++ b/src/cmd/ne-image-sorter/main_test.go @@ -0,0 +1,126 @@ +package main + +import ( + "bytes" + "image" + "image/color" + "image/jpeg" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/rs/zerolog" + + "github.com/nerdexecutive/ne-image-sorter/internal/domain" + "github.com/nerdexecutive/ne-image-sorter/internal/repository" + "github.com/nerdexecutive/ne-image-sorter/internal/sorter" +) + +// writeJPEG renders a w-by-h JPEG at path. +func writeJPEG(t *testing.T, path string, w, h int) { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + img.Set(0, 0, color.RGBA{R: 1, G: 2, B: 3, A: 255}) + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, nil); err != nil { + t.Fatalf("encode: %v", err) + } + if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil { + t.Fatalf("write: %v", err) + } +} + +// fixture builds a source directory holding one 16:9 image that stays and one +// ultrawide image that moves, and returns a matching config. +func fixture(t *testing.T) domain.Config { + t.Helper() + src := t.TempDir() + writeJPEG(t, filepath.Join(src, "keep-16x9.jpg"), 1920, 1088) // 16:9, over 1080 tall + writeJPEG(t, filepath.Join(src, "move-wide.jpg"), 3440, 1440) // 2.39:1 + + cfg := domain.DefaultConfig() + cfg.SourceDir = src + cfg.DestDir = filepath.Join(src, "rejects") + return cfg +} + +func newService() *sorter.Service { + return sorter.New(repository.NewFileImages(), zerolog.New(io.Discard)) +} + +func TestSortHeadlessMovesAndReports(t *testing.T) { + cfg := fixture(t) + var out bytes.Buffer + + if err := sortHeadless(&out, newService(), cfg, false); err != nil { + t.Fatalf("sortHeadless returned error: %v", err) + } + + got := out.String() + for _, want := range []string{"move-wide.jpg", "1 to move", "1 to keep", "2 scanned", "moved 1, failed 0"} { + if !strings.Contains(got, want) { + t.Errorf("output missing %q, got:\n%s", want, got) + } + } + if strings.Contains(got, "move keep-16x9.jpg") { + t.Error("the keep decision must not be listed as a move") + } + + if _, err := os.Stat(filepath.Join(cfg.DestDir, "move-wide.jpg")); err != nil { + t.Errorf("the moved file is not in the destination: %v", err) + } + if _, err := os.Stat(filepath.Join(cfg.SourceDir, "keep-16x9.jpg")); err != nil { + t.Errorf("the kept file left the source: %v", err) + } +} + +func TestSortHeadlessDryRunMovesNothing(t *testing.T) { + cfg := fixture(t) + var out bytes.Buffer + + if err := sortHeadless(&out, newService(), cfg, true); err != nil { + t.Fatalf("sortHeadless returned error: %v", err) + } + if !strings.Contains(out.String(), "dry run: nothing was moved") { + t.Error("a dry run must say so") + } + if _, err := os.Stat(filepath.Join(cfg.SourceDir, "move-wide.jpg")); err != nil { + t.Error("a dry run moved a file") + } + if _, err := os.Stat(cfg.DestDir); !os.IsNotExist(err) { + t.Error("a dry run created the destination directory") + } +} + +func TestSortHeadlessRejectsAnInvalidConfig(t *testing.T) { + var out bytes.Buffer + err := sortHeadless(&out, newService(), domain.Config{}, false) + if err == nil { + t.Fatal("sortHeadless on an empty config = nil error, want an error") + } + if !strings.Contains(err.Error(), "headless sort") { + t.Errorf("error %q is not wrapped with its operation", err) + } +} + +func TestSortHeadlessMissingSourceDirectory(t *testing.T) { + cfg := domain.DefaultConfig() + cfg.SourceDir = filepath.Join(t.TempDir(), "absent") + cfg.DestDir = filepath.Join(t.TempDir(), "rejects") + + var out bytes.Buffer + if err := sortHeadless(&out, newService(), cfg, false); err == nil { + t.Error("a missing source directory must be reported, not silently empty") + } +} + +func TestDefaultPathsAreAbsoluteOrLocal(t *testing.T) { + if p := defaultConfigPath(); p == "" { + t.Error("defaultConfigPath returned an empty path") + } + if p := defaultLogDir(); p == "" { + t.Error("defaultLogDir returned an empty path") + } +} diff --git a/src/go.mod b/src/go.mod new file mode 100644 index 0000000..e67e6ce --- /dev/null +++ b/src/go.mod @@ -0,0 +1,31 @@ +module github.com/nerdexecutive/ne-image-sorter + +go 1.26.5 + +require ( + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/rs/zerolog v1.35.1 + golang.org/x/image v0.45.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect +) diff --git a/src/go.sum b/src/go.sum new file mode 100644 index 0000000..8f4ff66 --- /dev/null +++ b/src/go.sum @@ -0,0 +1,49 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0= +golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= diff --git a/src/internal/domain/domain.go b/src/internal/domain/domain.go new file mode 100644 index 0000000..edeadcd --- /dev/null +++ b/src/internal/domain/domain.go @@ -0,0 +1,260 @@ +// Package domain holds the sorting vocabulary: the image record, the rule +// language that classifies one, and the configuration that carries a policy. +// It depends on nothing outside the standard library so the policy stays +// testable without a filesystem. +package domain + +import ( + "fmt" + "math" + "strconv" + "strings" +) + +// Image is one discovered image file and its pixel dimensions. +type Image struct { + Path string `json:"path"` + Name string `json:"name"` + Width int `json:"width"` + Height int `json:"height"` +} + +// Aspect returns the width-to-height ratio, or 0 when the height is unusable. +func (i Image) Aspect() float64 { + if i.Height <= 0 { + return 0 + } + return float64(i.Width) / float64(i.Height) +} + +// Resolution renders the dimensions for display, for example "3840x2160". +func (i Image) Resolution() string { + return fmt.Sprintf("%dx%d", i.Width, i.Height) +} + +// Field names the image property a rule tests. +type Field string + +// The testable image properties. +const ( + FieldAspect Field = "aspect" + FieldWidth Field = "width" + FieldHeight Field = "height" +) + +// Fields lists every valid field in menu order. +func Fields() []Field { return []Field{FieldAspect, FieldWidth, FieldHeight} } + +// Operator is the comparison a rule applies to a field. +type Operator string + +// The supported comparisons. Tolerance applies to OpEqual and OpNotEqual only. +const ( + OpEqual Operator = "==" + OpNotEqual Operator = "!=" + OpLess Operator = "<" + OpLessEqual Operator = "<=" + OpGreater Operator = ">" + OpGreaterEqual Operator = ">=" +) + +// Operators lists every valid operator in menu order. +func Operators() []Operator { + return []Operator{OpEqual, OpNotEqual, OpLess, OpLessEqual, OpGreater, OpGreaterEqual} +} + +// Action is the verdict a rule or a policy default produces. +type Action string + +// The two verdicts. Move relocates the file; Keep leaves it in the source. +const ( + ActionMove Action = "move" + ActionKeep Action = "keep" +) + +// Rule is one ordered policy entry: a comparison against a field, and the +// verdict to return when it matches. Tolerance is a fraction of Value, so +// 0.01 means one percent, and it is read only by OpEqual and OpNotEqual. +type Rule struct { + Field Field `json:"field"` + Op Operator `json:"op"` + Value float64 `json:"value"` + Tolerance float64 `json:"tolerance"` + Action Action `json:"action"` + Label string `json:"label,omitempty"` +} + +// actual reads the field this rule tests from img. +func (r Rule) actual(img Image) (float64, bool) { + switch r.Field { + case FieldAspect: + return img.Aspect(), true + case FieldWidth: + return float64(img.Width), true + case FieldHeight: + return float64(img.Height), true + default: + return 0, false + } +} + +// Matches reports whether img satisfies this rule. An unknown field or +// operator never matches, so a malformed rule is inert rather than fatal. +func (r Rule) Matches(img Image) bool { + actual, ok := r.actual(img) + if !ok { + return false + } + switch r.Op { + case OpEqual: + return within(actual, r.Value, r.Tolerance) + case OpNotEqual: + return !within(actual, r.Value, r.Tolerance) + case OpLess: + return actual < r.Value + case OpLessEqual: + return actual <= r.Value + case OpGreater: + return actual > r.Value + case OpGreaterEqual: + return actual >= r.Value + default: + return false + } +} + +// within reports whether actual is within a relative tolerance of target. +// A zero target falls back to an exact comparison, because a relative band +// around zero has no width. +func within(actual, target, tolerance float64) bool { + if target == 0 { + return actual == 0 + } + return math.Abs(actual-target)/math.Abs(target) <= tolerance +} + +// Describe renders the rule as one readable line for the interface. +func (r Rule) Describe() string { + value := r.Label + if value == "" { + value = formatValue(r.Field, r.Value) + } + out := fmt.Sprintf("%s if %s %s %s", r.Action, r.Field, r.Op, value) + if r.Tolerance > 0 && (r.Op == OpEqual || r.Op == OpNotEqual) { + out += fmt.Sprintf(" (+/-%s)", FormatTolerance(r.Tolerance)) + } + return out +} + +// formatValue renders a bare rule value: ratios to four places, pixel counts +// as whole numbers. +func formatValue(field Field, v float64) string { + if field == FieldAspect { + return strconv.FormatFloat(v, 'f', 4, 64) + } + return strconv.FormatFloat(v, 'f', -1, 64) +} + +// FormatTolerance renders a fractional tolerance as a percentage, for example +// 0.015 as "1.5%". +func FormatTolerance(t float64) string { + return strings.TrimSuffix(strconv.FormatFloat(t*100, 'f', -1, 64), ".0") + "%" +} + +// ParseRatio reads an aspect ratio written either as a pair, "16:9" or +// "16/10", or as a bare decimal such as "1.7778". +func ParseRatio(s string) (float64, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, fmt.Errorf("parse ratio: value is empty") + } + sep := strings.IndexAny(s, ":/") + if sep < 0 { + v, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0, fmt.Errorf("parse ratio %q: %w", s, err) + } + if v <= 0 { + return 0, fmt.Errorf("parse ratio %q: must be greater than zero", s) + } + return v, nil + } + w, err := strconv.ParseFloat(strings.TrimSpace(s[:sep]), 64) + if err != nil { + return 0, fmt.Errorf("parse ratio %q: bad width term: %w", s, err) + } + h, err := strconv.ParseFloat(strings.TrimSpace(s[sep+1:]), 64) + if err != nil { + return 0, fmt.Errorf("parse ratio %q: bad height term: %w", s, err) + } + if w <= 0 || h <= 0 { + return 0, fmt.Errorf("parse ratio %q: both terms must be greater than zero", s) + } + return w / h, nil +} + +// Policy is an ordered rule list with a fallback. The first rule that matches +// decides, which is what lets a narrow move rule sit above broad keep rules. +type Policy struct { + Rules []Rule `json:"rules"` + Default Action `json:"default"` +} + +// Decide returns the verdict for img and the index of the rule that produced +// it. The index is -1 when no rule matched and the default applied. +func (p Policy) Decide(img Image) (Action, int) { + for i, r := range p.Rules { + if r.Matches(img) { + return r.Action, i + } + } + return p.Default, -1 +} + +// DefaultPolicy is the shipped starting point: move anything 1080 pixels tall +// or shorter, keep 16:9 and 16:10 within one percent, and move the rest. It +// reproduces the wallpaper collection's own hand sort, which its test pins. +func DefaultPolicy() Policy { + return Policy{ + Rules: []Rule{ + {Field: FieldHeight, Op: OpLessEqual, Value: 1080, Action: ActionMove}, + {Field: FieldAspect, Op: OpEqual, Value: 16.0 / 9.0, Tolerance: 0.01, Action: ActionKeep, Label: "16:9"}, + {Field: FieldAspect, Op: OpEqual, Value: 16.0 / 10.0, Tolerance: 0.01, Action: ActionKeep, Label: "16:10"}, + }, + Default: ActionMove, + } +} + +// Config is the complete saved state: where to read, where to move, what to +// consider an image, and the policy to apply. +type Config struct { + SourceDir string `json:"source_dir"` + DestDir string `json:"dest_dir"` + Extensions []string `json:"extensions"` + Policy Policy `json:"policy"` +} + +// Validate reports the first reason this config cannot drive a sort. It checks +// shape only, never the filesystem, so it stays usable while the user is still +// typing a path. +func (c Config) Validate() error { + switch { + case strings.TrimSpace(c.SourceDir) == "": + return fmt.Errorf("validate config: source directory is not set") + case strings.TrimSpace(c.DestDir) == "": + return fmt.Errorf("validate config: destination directory is not set") + case c.SourceDir == c.DestDir: + return fmt.Errorf("validate config: source and destination are the same directory") + case len(c.Extensions) == 0: + return fmt.Errorf("validate config: no file extensions are enabled") + } + return nil +} + +// DefaultConfig is a new configuration with no directories chosen yet. +func DefaultConfig() Config { + return Config{ + Extensions: []string{".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff"}, + Policy: DefaultPolicy(), + } +} diff --git a/src/internal/domain/domain_test.go b/src/internal/domain/domain_test.go new file mode 100644 index 0000000..de79a89 --- /dev/null +++ b/src/internal/domain/domain_test.go @@ -0,0 +1,233 @@ +package domain + +import "testing" + +func TestImageAspect(t *testing.T) { + tests := []struct { + name string + w, h int + want float64 + }{ + {"16:9 UHD", 3840, 2160, 16.0 / 9.0}, + {"16:10 WUXGA", 1920, 1200, 1.6}, + {"square", 1000, 1000, 1.0}, + {"zero height guards", 1920, 0, 0}, + {"negative height guards", 1920, -1, 0}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := Image{Width: tc.w, Height: tc.h}.Aspect() + if diff := got - tc.want; diff > 1e-9 || diff < -1e-9 { + t.Errorf("Aspect() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestParseRatio(t *testing.T) { + tests := []struct { + in string + want float64 + wantErr bool + }{ + {"16:9", 16.0 / 9.0, false}, + {"16/10", 1.6, false}, + {" 21 : 9 ", 21.0 / 9.0, false}, + {"1.7778", 1.7778, false}, + {"2", 2, false}, + {"16:0", 0, true}, + {"a:b", 0, true}, + {"", 0, true}, + {"-3:2", 0, true}, + } + for _, tc := range tests { + t.Run(tc.in, func(t *testing.T) { + got, err := ParseRatio(tc.in) + if tc.wantErr { + if err == nil { + t.Fatalf("ParseRatio(%q) = %v, want error", tc.in, got) + } + return + } + if err != nil { + t.Fatalf("ParseRatio(%q) returned error: %v", tc.in, err) + } + if diff := got - tc.want; diff > 1e-9 || diff < -1e-9 { + t.Errorf("ParseRatio(%q) = %v, want %v", tc.in, got, tc.want) + } + }) + } +} + +func TestRuleMatches(t *testing.T) { + uhd := Image{Width: 3840, Height: 2160} // 16:9, 1.7778 + odd := Image{Width: 2912, Height: 1632} // 1.7843, 0.37% off 16:9 + wide := Image{Width: 3440, Height: 1440} // 2.3889 + small := Image{Width: 1920, Height: 1080} // 16:9 but 1080 high + + tests := []struct { + name string + rule Rule + img Image + want bool + }{ + {"height lte matches at boundary", Rule{Field: FieldHeight, Op: OpLessEqual, Value: 1080}, small, true}, + {"height lte misses above boundary", Rule{Field: FieldHeight, Op: OpLessEqual, Value: 1080}, uhd, false}, + {"height lt misses at boundary", Rule{Field: FieldHeight, Op: OpLess, Value: 1080}, small, false}, + {"height lt matches below boundary", Rule{Field: FieldHeight, Op: OpLess, Value: 1080}, Image{Width: 1366, Height: 768}, true}, + {"width gte matches", Rule{Field: FieldWidth, Op: OpGreaterEqual, Value: 3840}, uhd, true}, + {"width gt misses at boundary", Rule{Field: FieldWidth, Op: OpGreater, Value: 3840}, uhd, false}, + {"aspect eq exact with zero tolerance", Rule{Field: FieldAspect, Op: OpEqual, Value: 16.0 / 9.0}, uhd, true}, + {"aspect eq rejects near miss at zero tolerance", Rule{Field: FieldAspect, Op: OpEqual, Value: 16.0 / 9.0}, odd, false}, + {"aspect eq accepts near miss within tolerance", Rule{Field: FieldAspect, Op: OpEqual, Value: 16.0 / 9.0, Tolerance: 0.01}, odd, true}, + {"aspect eq rejects outside tolerance", Rule{Field: FieldAspect, Op: OpEqual, Value: 16.0 / 9.0, Tolerance: 0.01}, wide, false}, + {"aspect ne inverts eq", Rule{Field: FieldAspect, Op: OpNotEqual, Value: 16.0 / 9.0, Tolerance: 0.01}, wide, true}, + {"aspect ne false inside tolerance", Rule{Field: FieldAspect, Op: OpNotEqual, Value: 16.0 / 9.0, Tolerance: 0.01}, odd, false}, + {"unknown field never matches", Rule{Field: "depth", Op: OpEqual, Value: 0}, uhd, false}, + {"unknown operator never matches", Rule{Field: FieldHeight, Op: "~=", Value: 2160}, uhd, false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := tc.rule.Matches(tc.img); got != tc.want { + t.Errorf("Matches() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestRuleMatchesZeroValueTolerance(t *testing.T) { + // A zero target with a relative tolerance must not divide by zero. + r := Rule{Field: FieldWidth, Op: OpEqual, Value: 0, Tolerance: 0.5} + if r.Matches(Image{Width: 0, Height: 100}) != true { + t.Error("zero equals zero should match") + } + if r.Matches(Image{Width: 10, Height: 100}) != false { + t.Error("non-zero must not match a zero target") + } +} + +func TestPolicyDecideFirstMatchWins(t *testing.T) { + p := DefaultPolicy() + + tests := []struct { + name string + img Image + want Action + wantRule int + }{ + {"1080-high 16:9 moves on the height rule", Image{Width: 1920, Height: 1080}, ActionMove, 0}, + {"UHD 16:9 keeps", Image{Width: 3840, Height: 2160}, ActionKeep, 1}, + {"WQXGA 16:10 keeps", Image{Width: 3840, Height: 2400}, ActionKeep, 2}, + {"near-16:9 inside tolerance keeps", Image{Width: 2912, Height: 1632}, ActionKeep, 1}, + {"ultrawide falls through to the default", Image{Width: 3440, Height: 1440}, ActionMove, -1}, + {"3:2 falls through to the default", Image{Width: 2560, Height: 1707}, ActionMove, -1}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gotAction, gotRule := p.Decide(tc.img) + if gotAction != tc.want || gotRule != tc.wantRule { + t.Errorf("Decide() = (%v, %d), want (%v, %d)", gotAction, gotRule, tc.want, tc.wantRule) + } + }) + } +} + +func TestPolicyDecideEmptyUsesDefault(t *testing.T) { + p := Policy{Default: ActionKeep} + if action, idx := p.Decide(Image{Width: 100, Height: 100}); action != ActionKeep || idx != -1 { + t.Errorf("Decide() = (%v, %d), want (keep, -1)", action, idx) + } +} + +// TestDefaultPolicyReproducesTheManualSort pins the default policy against the +// wallpaper collection's own 2025-12-17 hand sort, so a change to the shipped +// defaults that would re-file Chris's existing library fails here first. +func TestDefaultPolicyReproducesTheManualSort(t *testing.T) { + moved := []Image{ + {Name: "adwaita-l.jpg", Width: 4096, Height: 4096}, + {Name: "the-sphere.png", Width: 3440, Height: 1440}, + {Name: "mountain.jpg", Width: 1920, Height: 1080}, + {Name: "groot_1.jpg", Width: 1366, Height: 768}, + {Name: "tree.jpg", Width: 1920, Height: 1280}, + {Name: "glowing-green-dew.jpg", Width: 6067, Height: 3467}, + {Name: "orange-art.jpg", Width: 3400, Height: 2024}, + {Name: "suzume1.jpg", Width: 8192, Height: 4320}, + {Name: "street1.jpg", Width: 3840, Height: 1875}, + {Name: "pexels-robert-clark.jpg", Width: 4936, Height: 3290}, + } + kept := []Image{ + {Name: "3d-tech.jpg", Width: 3840, Height: 2160}, + {Name: "wallhaven3.jpg", Width: 1920, Height: 1200}, + {Name: "Fantasy-Landscape3.png", Width: 3831, Height: 2160}, + {Name: "golden-horizon.jpg", Width: 3200, Height: 1793}, + {Name: "comet.jpg", Width: 1920, Height: 1081}, + {Name: "anime3.png", Width: 5118, Height: 2878}, + {Name: "tron_legacy6.jpg", Width: 2880, Height: 1800}, + } + + p := DefaultPolicy() + for _, img := range moved { + if action, _ := p.Decide(img); action != ActionMove { + t.Errorf("%s (%dx%d): got %v, want move", img.Name, img.Width, img.Height, action) + } + } + for _, img := range kept { + if action, _ := p.Decide(img); action != ActionKeep { + t.Errorf("%s (%dx%d): got %v, want keep", img.Name, img.Width, img.Height, action) + } + } +} + +func TestRuleDescribe(t *testing.T) { + tests := []struct { + rule Rule + want string + }{ + {Rule{Field: FieldHeight, Op: OpLessEqual, Value: 1080, Action: ActionMove}, "move if height <= 1080"}, + {Rule{Field: FieldAspect, Op: OpEqual, Value: 16.0 / 9.0, Tolerance: 0.01, Action: ActionKeep, Label: "16:9"}, "keep if aspect == 16:9 (+/-1%)"}, + {Rule{Field: FieldAspect, Op: OpGreater, Value: 2, Action: ActionMove}, "move if aspect > 2.0000"}, + } + for _, tc := range tests { + t.Run(tc.want, func(t *testing.T) { + if got := tc.rule.Describe(); got != tc.want { + t.Errorf("Describe() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestConfigValidate(t *testing.T) { + valid := Config{SourceDir: "/a", DestDir: "/a/sub", Extensions: []string{".jpg"}, Policy: DefaultPolicy()} + if err := valid.Validate(); err != nil { + t.Fatalf("Validate() on a valid config returned: %v", err) + } + + tests := []struct { + name string + cfg Config + }{ + {"empty source", Config{DestDir: "/a/sub", Extensions: []string{".jpg"}}}, + {"empty destination", Config{SourceDir: "/a", Extensions: []string{".jpg"}}}, + {"identical directories", Config{SourceDir: "/a", DestDir: "/a", Extensions: []string{".jpg"}}}, + {"no extensions", Config{SourceDir: "/a", DestDir: "/a/sub"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if err := tc.cfg.Validate(); err == nil { + t.Error("Validate() = nil, want error") + } + }) + } +} + +func TestDefaultConfigIsValid(t *testing.T) { + cfg := DefaultConfig() + cfg.SourceDir = "/tmp/pics" + cfg.DestDir = "/tmp/pics/rejects" + if err := cfg.Validate(); err != nil { + t.Fatalf("DefaultConfig() is not usable: %v", err) + } + if len(cfg.Extensions) == 0 { + t.Error("DefaultConfig() must ship a non-empty extension list") + } +} diff --git a/src/internal/logging/logging.go b/src/internal/logging/logging.go new file mode 100644 index 0000000..addcadd --- /dev/null +++ b/src/internal/logging/logging.go @@ -0,0 +1,32 @@ +// Package logging opens the application's log file. Output goes to a file and +// never to the console: [Rule: Log to Files] requires it, and a terminal UI +// redraws the screen continuously, so a stray console write corrupts the +// display rather than informing anyone. +package logging + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/rs/zerolog" +) + +// Open creates dir if needed and returns a logger writing to +// ne-image-sorter.log inside it, plus the file to close on shutdown. +func Open(dir string) (zerolog.Logger, *os.File, error) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return zerolog.Nop(), nil, fmt.Errorf("open log directory %s: %w", dir, err) + } + + path := filepath.Join(dir, "ne-image-sorter.log") + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return zerolog.Nop(), nil, fmt.Errorf("open log file %s: %w", path, err) + } + + log := zerolog.New(f).With().Timestamp().Logger() + log.Info().Str("version", "dev").Time("started", time.Now()).Msg("ne-image-sorter started") + return log, f, nil +} diff --git a/src/internal/repository/config_json.go b/src/internal/repository/config_json.go new file mode 100644 index 0000000..5c581af --- /dev/null +++ b/src/internal/repository/config_json.go @@ -0,0 +1,53 @@ +package repository + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/nerdexecutive/ne-image-sorter/internal/domain" +) + +// JSONConfig is the file-backed Config repository. +type JSONConfig struct { + path string +} + +// NewJSONConfig returns a Config repository stored as JSON at path. +func NewJSONConfig(path string) *JSONConfig { return &JSONConfig{path: path} } + +// Path returns the file this repository reads and writes. +func (r *JSONConfig) Path() string { return r.path } + +// Load implements Config. +func (r *JSONConfig) Load() (domain.Config, error) { + data, err := os.ReadFile(r.path) + if os.IsNotExist(err) { + return domain.DefaultConfig(), nil + } + if err != nil { + return domain.Config{}, fmt.Errorf("load config from %s: %w", r.path, err) + } + + cfg := domain.DefaultConfig() + if err := json.Unmarshal(data, &cfg); err != nil { + return domain.Config{}, fmt.Errorf("parse config at %s: %w", r.path, err) + } + return cfg, nil +} + +// Save implements Config. +func (r *JSONConfig) Save(cfg domain.Config) error { + if err := os.MkdirAll(filepath.Dir(r.path), 0o755); err != nil { + return fmt.Errorf("save config: create %s: %w", filepath.Dir(r.path), err) + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("save config: encode: %w", err) + } + if err := os.WriteFile(r.path, append(data, '\n'), 0o644); err != nil { + return fmt.Errorf("save config to %s: %w", r.path, err) + } + return nil +} diff --git a/src/internal/repository/image_fs.go b/src/internal/repository/image_fs.go new file mode 100644 index 0000000..9f177c1 --- /dev/null +++ b/src/internal/repository/image_fs.go @@ -0,0 +1,143 @@ +package repository + +import ( + "fmt" + "image" + "io" + "os" + "path/filepath" + "sort" + "strings" + + // Registered for their image.DecodeConfig side effect, which reads + // dimensions from the file header without decoding the pixels. + _ "image/gif" + _ "image/jpeg" + _ "image/png" + + _ "golang.org/x/image/bmp" + _ "golang.org/x/image/tiff" + _ "golang.org/x/image/webp" + + "github.com/nerdexecutive/ne-image-sorter/internal/domain" +) + +// FileImages is the filesystem-backed Images repository. +type FileImages struct{} + +// NewFileImages returns an Images repository reading the local filesystem. +func NewFileImages() *FileImages { return &FileImages{} } + +// List implements Images. +func (r *FileImages) List(dir string, extensions []string) ([]domain.Image, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("list images in %s: %w", dir, err) + } + + enabled := make(map[string]struct{}, len(extensions)) + for _, e := range extensions { + enabled[strings.ToLower(e)] = struct{}{} + } + + var out []domain.Image + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if _, ok := enabled[strings.ToLower(filepath.Ext(name))]; !ok { + continue + } + path := filepath.Join(dir, name) + w, h, err := readDimensions(path) + if err != nil { + // An unreadable or non-image file is not a scan failure. Skipping + // it keeps one bad file from stopping a sort of hundreds. + continue + } + out = append(out, domain.Image{Path: path, Name: name, Width: w, Height: h}) + } + + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil +} + +// readDimensions reads an image's pixel dimensions from its header. +func readDimensions(path string) (int, int, error) { + f, err := os.Open(path) + if err != nil { + return 0, 0, fmt.Errorf("open %s: %w", path, err) + } + defer f.Close() + + cfg, _, err := image.DecodeConfig(f) + if err != nil { + return 0, 0, fmt.Errorf("decode header of %s: %w", path, err) + } + return cfg.Width, cfg.Height, nil +} + +// Move implements Images. +func (r *FileImages) Move(img domain.Image, destDir string) (string, error) { + if _, err := os.Stat(img.Path); err != nil { + return "", fmt.Errorf("move %s: source unreadable: %w", img.Name, err) + } + if err := os.MkdirAll(destDir, 0o755); err != nil { + return "", fmt.Errorf("move %s: create %s: %w", img.Name, destDir, err) + } + + target, err := freeName(destDir, img.Name) + if err != nil { + return "", fmt.Errorf("move %s: %w", img.Name, err) + } + if err := os.Rename(img.Path, target); err != nil { + // Rename fails across filesystems, so fall back to a copy. + if err := copyFile(img.Path, target); err != nil { + return "", fmt.Errorf("move %s: %w", img.Name, err) + } + if err := os.Remove(img.Path); err != nil { + return "", fmt.Errorf("move %s: remove source after copy: %w", img.Name, err) + } + } + return target, nil +} + +// freeName returns a path in dir that no file occupies, suffixing the base +// name with -1, -2 and so on until one is free. +func freeName(dir, name string) (string, error) { + ext := filepath.Ext(name) + base := strings.TrimSuffix(name, ext) + for i := 0; i < 1000; i++ { + candidate := filepath.Join(dir, name) + if i > 0 { + candidate = filepath.Join(dir, fmt.Sprintf("%s-%d%s", base, i, ext)) + } + if _, err := os.Stat(candidate); os.IsNotExist(err) { + return candidate, nil + } + } + return "", fmt.Errorf("no free name for %s in %s after 1000 attempts", name, dir) +} + +// copyFile writes src to dst, which must not already exist. +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return fmt.Errorf("open source %s: %w", src, err) + } + defer in.Close() + + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + return fmt.Errorf("create destination %s: %w", dst, err) + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + return fmt.Errorf("copy to %s: %w", dst, err) + } + if err := out.Close(); err != nil { + return fmt.Errorf("close destination %s: %w", dst, err) + } + return nil +} diff --git a/src/internal/repository/repository.go b/src/internal/repository/repository.go new file mode 100644 index 0000000..b021e40 --- /dev/null +++ b/src/internal/repository/repository.go @@ -0,0 +1,29 @@ +// Package repository isolates every filesystem concern behind two narrow +// interfaces, so the sorting service can be driven against fakes and the +// concrete implementations can be swapped without touching the policy code. +package repository + +import "github.com/nerdexecutive/ne-image-sorter/internal/domain" + +// Images is the collection of image files the sorter reads and relocates. +type Images interface { + // List returns every file in dir whose extension is enabled, with its + // pixel dimensions read from the file header. Files that are not + // decodable images are skipped rather than reported as errors. + List(dir string, extensions []string) ([]domain.Image, error) + + // Move relocates img into destDir, creating the directory if needed, and + // returns the path it was written to. An existing file at the target name + // is never overwritten; the moved file is suffixed instead. + Move(img domain.Image, destDir string) (string, error) +} + +// Config is the persisted configuration. +type Config interface { + // Load returns the stored configuration, or the shipped defaults when + // nothing has been saved yet. + Load() (domain.Config, error) + + // Save writes the configuration, creating the parent directory if needed. + Save(cfg domain.Config) error +} diff --git a/src/internal/repository/repository_test.go b/src/internal/repository/repository_test.go new file mode 100644 index 0000000..84b9d8a --- /dev/null +++ b/src/internal/repository/repository_test.go @@ -0,0 +1,240 @@ +package repository + +import ( + "bytes" + "image" + "image/color" + "image/gif" + "image/jpeg" + "image/png" + "os" + "path/filepath" + "testing" + + "github.com/nerdexecutive/ne-image-sorter/internal/domain" +) + +// writeImage renders a w-by-h image at path in the format implied by its +// extension, so the dimension reader is exercised against real file headers. +func writeImage(t *testing.T, path string, w, h int) { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + img.Set(0, 0, color.RGBA{R: 1, G: 2, B: 3, A: 255}) + + var buf bytes.Buffer + var err error + switch filepath.Ext(path) { + case ".png": + err = png.Encode(&buf, img) + case ".gif": + err = gif.Encode(&buf, img, nil) + default: + err = jpeg.Encode(&buf, img, nil) + } + if err != nil { + t.Fatalf("encode %s: %v", path, err) + } + if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func TestFileImagesListReadsDimensions(t *testing.T) { + dir := t.TempDir() + writeImage(t, filepath.Join(dir, "uhd.jpg"), 384, 216) + writeImage(t, filepath.Join(dir, "tall.png"), 100, 200) + writeImage(t, filepath.Join(dir, "anim.gif"), 64, 64) + + got, err := NewFileImages().List(dir, []string{".jpg", ".png", ".gif"}) + if err != nil { + t.Fatalf("List() returned error: %v", err) + } + if len(got) != 3 { + t.Fatalf("List() returned %d images, want 3", len(got)) + } + + want := map[string][2]int{"uhd.jpg": {384, 216}, "tall.png": {100, 200}, "anim.gif": {64, 64}} + for _, img := range got { + w, ok := want[img.Name] + if !ok { + t.Errorf("unexpected image %q", img.Name) + continue + } + if img.Width != w[0] || img.Height != w[1] { + t.Errorf("%s = %dx%d, want %dx%d", img.Name, img.Width, img.Height, w[0], w[1]) + } + if img.Path != filepath.Join(dir, img.Name) { + t.Errorf("%s path = %q, want an absolute path under the source", img.Name, img.Path) + } + } +} + +func TestFileImagesListIsSortedByName(t *testing.T) { + dir := t.TempDir() + for _, n := range []string{"c.jpg", "a.jpg", "b.jpg"} { + writeImage(t, filepath.Join(dir, n), 10, 10) + } + got, err := NewFileImages().List(dir, []string{".jpg"}) + if err != nil { + t.Fatalf("List() returned error: %v", err) + } + for i, want := range []string{"a.jpg", "b.jpg", "c.jpg"} { + if got[i].Name != want { + t.Errorf("List()[%d] = %q, want %q", i, got[i].Name, want) + } + } +} + +func TestFileImagesListFiltersAndSkips(t *testing.T) { + dir := t.TempDir() + writeImage(t, filepath.Join(dir, "keep.jpg"), 10, 10) + writeImage(t, filepath.Join(dir, "wrong-ext.png"), 10, 10) + if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + // A file with an enabled extension whose bytes are not an image must be + // skipped rather than aborting the scan. + if err := os.WriteFile(filepath.Join(dir, "broken.jpg"), []byte("not an image"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(dir, "sub.jpg"), 0o755); err != nil { + t.Fatal(err) + } + + got, err := NewFileImages().List(dir, []string{".jpg"}) + if err != nil { + t.Fatalf("List() returned error: %v", err) + } + if len(got) != 1 || got[0].Name != "keep.jpg" { + t.Fatalf("List() = %+v, want only keep.jpg", got) + } +} + +func TestFileImagesListExtensionMatchIsCaseInsensitive(t *testing.T) { + dir := t.TempDir() + writeImage(t, filepath.Join(dir, "shouty.JPG"), 10, 10) + got, err := NewFileImages().List(dir, []string{".jpg"}) + if err != nil { + t.Fatalf("List() returned error: %v", err) + } + if len(got) != 1 { + t.Fatalf("List() returned %d images, want 1", len(got)) + } +} + +func TestFileImagesListMissingDirectory(t *testing.T) { + if _, err := NewFileImages().List(filepath.Join(t.TempDir(), "absent"), []string{".jpg"}); err == nil { + t.Error("List() on a missing directory = nil error, want an error") + } +} + +func TestFileImagesMoveCreatesDestination(t *testing.T) { + src, dst := t.TempDir(), filepath.Join(t.TempDir(), "rejects") + writeImage(t, filepath.Join(src, "a.jpg"), 10, 10) + + img := domain.Image{Path: filepath.Join(src, "a.jpg"), Name: "a.jpg"} + got, err := NewFileImages().Move(img, dst) + if err != nil { + t.Fatalf("Move() returned error: %v", err) + } + if want := filepath.Join(dst, "a.jpg"); got != want { + t.Errorf("Move() = %q, want %q", got, want) + } + if _, err := os.Stat(got); err != nil { + t.Errorf("destination file missing: %v", err) + } + if _, err := os.Stat(img.Path); !os.IsNotExist(err) { + t.Error("source file still present after Move()") + } +} + +func TestFileImagesMoveDoesNotOverwrite(t *testing.T) { + src, dst := t.TempDir(), t.TempDir() + writeImage(t, filepath.Join(src, "a.jpg"), 10, 10) + writeImage(t, filepath.Join(dst, "a.jpg"), 99, 99) + + img := domain.Image{Path: filepath.Join(src, "a.jpg"), Name: "a.jpg"} + got, err := NewFileImages().Move(img, dst) + if err != nil { + t.Fatalf("Move() returned error: %v", err) + } + if want := filepath.Join(dst, "a-1.jpg"); got != want { + t.Fatalf("Move() = %q, want %q", got, want) + } + // The pre-existing file must be untouched at its original dimensions. + existing, err := NewFileImages().List(dst, []string{".jpg"}) + if err != nil { + t.Fatal(err) + } + for _, e := range existing { + if e.Name == "a.jpg" && (e.Width != 99 || e.Height != 99) { + t.Error("Move() overwrote the pre-existing destination file") + } + } +} + +func TestFileImagesMoveMissingSource(t *testing.T) { + img := domain.Image{Path: filepath.Join(t.TempDir(), "gone.jpg"), Name: "gone.jpg"} + if _, err := NewFileImages().Move(img, t.TempDir()); err == nil { + t.Error("Move() on a missing source = nil error, want an error") + } +} + +func TestJSONConfigRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "config.json") + repo := NewJSONConfig(path) + + want := domain.DefaultConfig() + want.SourceDir = "/pics" + want.DestDir = "/pics/rejects" + if err := repo.Save(want); err != nil { + t.Fatalf("Save() returned error: %v", err) + } + + got, err := repo.Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + if got.SourceDir != want.SourceDir || got.DestDir != want.DestDir { + t.Errorf("Load() directories = %q, %q", got.SourceDir, got.DestDir) + } + if len(got.Policy.Rules) != len(want.Policy.Rules) { + t.Fatalf("Load() returned %d rules, want %d", len(got.Policy.Rules), len(want.Policy.Rules)) + } + for i, r := range got.Policy.Rules { + if r != want.Policy.Rules[i] { + t.Errorf("rule %d = %+v, want %+v", i, r, want.Policy.Rules[i]) + } + } + if got.Policy.Default != want.Policy.Default { + t.Errorf("Load() default = %q, want %q", got.Policy.Default, want.Policy.Default) + } +} + +func TestJSONConfigLoadMissingFileReturnsDefaults(t *testing.T) { + repo := NewJSONConfig(filepath.Join(t.TempDir(), "absent.json")) + got, err := repo.Load() + if err != nil { + t.Fatalf("Load() on a missing file returned error: %v", err) + } + if len(got.Policy.Rules) != len(domain.DefaultPolicy().Rules) { + t.Error("Load() on a missing file must return the shipped defaults") + } +} + +func TestJSONConfigLoadCorruptFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := NewJSONConfig(path).Load(); err == nil { + t.Error("Load() on a corrupt file = nil error, want an error") + } +} + +// Compile-time proof that both concrete types satisfy the interfaces the +// service depends on. +var ( + _ Images = (*FileImages)(nil) + _ Config = (*JSONConfig)(nil) +) diff --git a/src/internal/sorter/service.go b/src/internal/sorter/service.go new file mode 100644 index 0000000..6490591 --- /dev/null +++ b/src/internal/sorter/service.go @@ -0,0 +1,131 @@ +// Package sorter applies a policy to a directory of images. It plans first +// and moves second, so the interface can show the user exactly what will +// happen before anything on disk changes. +package sorter + +import ( + "fmt" + + "github.com/rs/zerolog" + + "github.com/nerdexecutive/ne-image-sorter/internal/domain" + "github.com/nerdexecutive/ne-image-sorter/internal/repository" +) + +// Decision is one image and the verdict the policy reached for it. +type Decision struct { + Image domain.Image + Action domain.Action + Reason string +} + +// Plan is a complete classification of a source directory. Nothing has moved. +type Plan struct { + Decisions []Decision + MoveCount int + KeepCount int +} + +// Moves returns just the decisions that relocate a file. +func (p Plan) Moves() []Decision { + out := make([]Decision, 0, p.MoveCount) + for _, d := range p.Decisions { + if d.Action == domain.ActionMove { + out = append(out, d) + } + } + return out +} + +// Report is the outcome of applying a plan. +type Report struct { + Moved int + Failed int + Errors []string +} + +// Service plans and applies sorts against an image repository. +type Service struct { + images repository.Images + log zerolog.Logger +} + +// New returns a Service reading and writing through images. +func New(images repository.Images, log zerolog.Logger) *Service { + return &Service{images: images, log: log} +} + +// Plan classifies every image in the configured source directory without +// changing anything on disk. +func (s *Service) Plan(cfg domain.Config) (Plan, error) { + if err := cfg.Validate(); err != nil { + return Plan{}, fmt.Errorf("plan sort: %w", err) + } + + images, err := s.images.List(cfg.SourceDir, cfg.Extensions) + if err != nil { + return Plan{}, fmt.Errorf("plan sort: %w", err) + } + + plan := Plan{Decisions: make([]Decision, 0, len(images))} + for _, img := range images { + action, ruleIdx := cfg.Policy.Decide(img) + plan.Decisions = append(plan.Decisions, Decision{ + Image: img, + Action: action, + Reason: reason(cfg.Policy, ruleIdx), + }) + if action == domain.ActionMove { + plan.MoveCount++ + } else { + plan.KeepCount++ + } + } + + s.log.Info(). + Str("source", cfg.SourceDir). + Int("scanned", len(images)). + Int("move", plan.MoveCount). + Int("keep", plan.KeepCount). + Msg("planned sort") + return plan, nil +} + +// reason renders why a decision was reached, naming the deciding rule or the +// policy default. +func reason(p domain.Policy, ruleIdx int) string { + if ruleIdx < 0 || ruleIdx >= len(p.Rules) { + return fmt.Sprintf("no rule matched, default is %s", p.Default) + } + return fmt.Sprintf("rule %d: %s", ruleIdx+1, p.Rules[ruleIdx].Describe()) +} + +// Apply performs the move decisions in plan. A single file that cannot be +// moved is recorded and the run continues, because one locked file must not +// abandon the rest of a sort part-done. +func (s *Service) Apply(cfg domain.Config, plan Plan) (Report, error) { + if err := cfg.Validate(); err != nil { + return Report{}, fmt.Errorf("apply sort: %w", err) + } + + var report Report + for _, d := range plan.Moves() { + dest, err := s.images.Move(d.Image, cfg.DestDir) + if err != nil { + report.Failed++ + report.Errors = append(report.Errors, fmt.Sprintf("%s: %v", d.Image.Name, err)) + s.log.Error().Err(err).Str("image", d.Image.Name).Msg("move failed") + continue + } + report.Moved++ + s.log.Info(). + Str("image", d.Image.Name). + Str("resolution", d.Image.Resolution()). + Str("dest", dest). + Str("reason", d.Reason). + Msg("moved") + } + + s.log.Info().Int("moved", report.Moved).Int("failed", report.Failed).Msg("applied sort") + return report, nil +} diff --git a/src/internal/sorter/service_test.go b/src/internal/sorter/service_test.go new file mode 100644 index 0000000..dff10a8 --- /dev/null +++ b/src/internal/sorter/service_test.go @@ -0,0 +1,211 @@ +package sorter + +import ( + "errors" + "io" + "path/filepath" + "strings" + "testing" + + "github.com/rs/zerolog" + + "github.com/nerdexecutive/ne-image-sorter/internal/domain" +) + +// fakeImages is an in-memory Images repository, so the service is tested +// without touching a filesystem and failure paths are reachable on demand. +type fakeImages struct { + images []domain.Image + listErr error + moveErr map[string]error + moved []string + moveDest string +} + +func (f *fakeImages) List(dir string, extensions []string) ([]domain.Image, error) { + if f.listErr != nil { + return nil, f.listErr + } + return f.images, nil +} + +func (f *fakeImages) Move(img domain.Image, destDir string) (string, error) { + if err, ok := f.moveErr[img.Name]; ok { + return "", err + } + f.moved = append(f.moved, img.Name) + f.moveDest = destDir + return filepath.Join(destDir, img.Name), nil +} + +func quietLogger() zerolog.Logger { return zerolog.New(io.Discard) } + +func testConfig(images ...domain.Image) (domain.Config, *fakeImages) { + cfg := domain.DefaultConfig() + cfg.SourceDir = "/pics" + cfg.DestDir = "/pics/rejects" + return cfg, &fakeImages{images: images} +} + +func TestPlanClassifiesEveryImage(t *testing.T) { + cfg, repo := testConfig( + domain.Image{Name: "uhd.jpg", Width: 3840, Height: 2160}, // keep, 16:9 + domain.Image{Name: "hd.jpg", Width: 1920, Height: 1080}, // move, height + domain.Image{Name: "ultrawide.jpg", Width: 3440, Height: 1440}, // move, default + domain.Image{Name: "wuxga.jpg", Width: 1920, Height: 1200}, // keep, 16:10 + ) + + plan, err := New(repo, quietLogger()).Plan(cfg) + if err != nil { + t.Fatalf("Plan() returned error: %v", err) + } + if len(plan.Decisions) != 4 { + t.Fatalf("Plan() produced %d decisions, want 4", len(plan.Decisions)) + } + if plan.MoveCount != 2 || plan.KeepCount != 2 { + t.Errorf("Plan() counts = %d move, %d keep; want 2 and 2", plan.MoveCount, plan.KeepCount) + } + + want := map[string]domain.Action{ + "uhd.jpg": domain.ActionKeep, "hd.jpg": domain.ActionMove, + "ultrawide.jpg": domain.ActionMove, "wuxga.jpg": domain.ActionKeep, + } + for _, d := range plan.Decisions { + if d.Action != want[d.Image.Name] { + t.Errorf("%s = %v, want %v", d.Image.Name, d.Action, want[d.Image.Name]) + } + if d.Reason == "" { + t.Errorf("%s has an empty reason", d.Image.Name) + } + } +} + +func TestPlanReasonNamesTheDecidingRule(t *testing.T) { + cfg, repo := testConfig( + domain.Image{Name: "hd.jpg", Width: 1920, Height: 1080}, + domain.Image{Name: "ultrawide.jpg", Width: 3440, Height: 1440}, + ) + plan, err := New(repo, quietLogger()).Plan(cfg) + if err != nil { + t.Fatalf("Plan() returned error: %v", err) + } + + byName := map[string]Decision{} + for _, d := range plan.Decisions { + byName[d.Image.Name] = d + } + if got := byName["hd.jpg"].Reason; !strings.Contains(got, "height") { + t.Errorf("hd.jpg reason = %q, want it to name the height rule", got) + } + if got := byName["ultrawide.jpg"].Reason; !strings.Contains(strings.ToLower(got), "default") { + t.Errorf("ultrawide.jpg reason = %q, want it to name the default", got) + } +} + +func TestPlanRejectsInvalidConfig(t *testing.T) { + repo := &fakeImages{} + if _, err := New(repo, quietLogger()).Plan(domain.Config{}); err == nil { + t.Error("Plan() on an invalid config = nil error, want an error") + } +} + +func TestPlanWrapsListError(t *testing.T) { + cfg, repo := testConfig() + sentinel := errors.New("disk on fire") + repo.listErr = sentinel + + _, err := New(repo, quietLogger()).Plan(cfg) + if err == nil { + t.Fatal("Plan() = nil error, want an error") + } + if !errors.Is(err, sentinel) { + t.Errorf("Plan() error %v does not wrap the repository error", err) + } +} + +func TestPlanEmptySource(t *testing.T) { + cfg, repo := testConfig() + plan, err := New(repo, quietLogger()).Plan(cfg) + if err != nil { + t.Fatalf("Plan() returned error: %v", err) + } + if len(plan.Decisions) != 0 || plan.MoveCount != 0 { + t.Error("Plan() on an empty source must produce an empty plan") + } +} + +func TestApplyMovesOnlyTheMoveDecisions(t *testing.T) { + cfg, repo := testConfig( + domain.Image{Name: "uhd.jpg", Width: 3840, Height: 2160}, + domain.Image{Name: "hd.jpg", Width: 1920, Height: 1080}, + domain.Image{Name: "ultrawide.jpg", Width: 3440, Height: 1440}, + ) + svc := New(repo, quietLogger()) + plan, err := svc.Plan(cfg) + if err != nil { + t.Fatalf("Plan() returned error: %v", err) + } + + report, err := svc.Apply(cfg, plan) + if err != nil { + t.Fatalf("Apply() returned error: %v", err) + } + if report.Moved != 2 || report.Failed != 0 { + t.Errorf("Apply() = %d moved, %d failed; want 2 and 0", report.Moved, report.Failed) + } + if len(repo.moved) != 2 { + t.Fatalf("repository saw %d moves, want 2", len(repo.moved)) + } + for _, name := range repo.moved { + if name == "uhd.jpg" { + t.Error("Apply() moved a keep decision") + } + } + if repo.moveDest != cfg.DestDir { + t.Errorf("Apply() moved into %q, want %q", repo.moveDest, cfg.DestDir) + } +} + +func TestApplyContinuesPastAFailureAndReportsIt(t *testing.T) { + cfg, repo := testConfig( + domain.Image{Name: "a.jpg", Width: 3440, Height: 1440}, + domain.Image{Name: "b.jpg", Width: 3440, Height: 1440}, + domain.Image{Name: "c.jpg", Width: 3440, Height: 1440}, + ) + repo.moveErr = map[string]error{"b.jpg": errors.New("permission denied")} + + svc := New(repo, quietLogger()) + plan, err := svc.Plan(cfg) + if err != nil { + t.Fatalf("Plan() returned error: %v", err) + } + + report, err := svc.Apply(cfg, plan) + if err != nil { + t.Fatalf("Apply() must not abort on a single failure, got: %v", err) + } + if report.Moved != 2 || report.Failed != 1 { + t.Errorf("Apply() = %d moved, %d failed; want 2 and 1", report.Moved, report.Failed) + } + if len(report.Errors) != 1 || !strings.Contains(report.Errors[0], "b.jpg") { + t.Errorf("Apply() errors = %v, want one naming b.jpg", report.Errors) + } +} + +func TestApplyRejectsInvalidConfig(t *testing.T) { + repo := &fakeImages{} + if _, err := New(repo, quietLogger()).Apply(domain.Config{}, Plan{}); err == nil { + t.Error("Apply() on an invalid config = nil error, want an error") + } +} + +func TestApplyEmptyPlanIsANoOp(t *testing.T) { + cfg, repo := testConfig() + report, err := New(repo, quietLogger()).Apply(cfg, Plan{}) + if err != nil { + t.Fatalf("Apply() returned error: %v", err) + } + if report.Moved != 0 || report.Failed != 0 || len(repo.moved) != 0 { + t.Error("Apply() on an empty plan must move nothing") + } +} diff --git a/src/internal/tui/app.go b/src/internal/tui/app.go new file mode 100644 index 0000000..315dee5 --- /dev/null +++ b/src/internal/tui/app.go @@ -0,0 +1,140 @@ +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/rs/zerolog" + + "github.com/nerdexecutive/ne-image-sorter/internal/domain" + "github.com/nerdexecutive/ne-image-sorter/internal/repository" + "github.com/nerdexecutive/ne-image-sorter/internal/sorter" +) + +// saveConfigMsg asks the root model to persist the configuration a screen +// changed. Screens never touch the repository themselves, so persistence +// stays in one place. +type saveConfigMsg struct{ cfg domain.Config } + +// saveConfig returns a command that persists cfg. +func saveConfig(cfg domain.Config) tea.Cmd { + return func() tea.Msg { return saveConfigMsg{cfg: cfg} } +} + +// App is the root Bubbletea model. It owns the configuration and routes +// between screens. +type App struct { + cfg domain.Config + configs repository.Config + svc *sorter.Service + log zerolog.Logger + + active Screen + menu menuModel + folders foldersModel + rules rulesModel + sort sortModel + + width int + height int +} + +// NewApp builds the root model from a loaded configuration. +func NewApp(cfg domain.Config, configs repository.Config, svc *sorter.Service, log zerolog.Logger) App { + return App{ + cfg: cfg, + configs: configs, + svc: svc, + log: log, + active: ScreenMenu, + menu: newMenuModel(cfg), + } +} + +// Init implements tea.Model. +func (a App) Init() tea.Cmd { return nil } + +// Update implements tea.Model. +func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + // ctrl+c quits from anywhere, on every screen, always. + if msg.String() == "ctrl+c" { + return a, tea.Quit + } + + case tea.WindowSizeMsg: + a.width, a.height = msg.Width, msg.Height + a.menu.width = msg.Width + a.folders.width = msg.Width + a.rules.width = msg.Width + a.sort.width, a.sort.height = msg.Width, msg.Height + return a, nil + + case saveConfigMsg: + a.cfg = msg.cfg + a.menu.cfg = msg.cfg + if err := a.configs.Save(msg.cfg); err != nil { + a.log.Error().Err(err).Msg("save config failed") + return a, notify("Could not save the configuration. See the log.", true) + } + a.log.Info().Msg("configuration saved") + return a, nil + + case statusMsg: + a.menu.status, a.menu.isErr = msg.text, msg.isErr + return a, nil + + case switchMsg: + return a.open(msg.screen) + } + + return a.route(msg) +} + +// open switches to a screen, building it fresh from the current config so it +// never shows stale state. +func (a App) open(s Screen) (tea.Model, tea.Cmd) { + a.active = s + switch s { + case ScreenFolders: + a.folders = newFoldersModel(a.cfg) + a.folders.width = a.width + case ScreenRules: + a.rules = newRulesModel(a.cfg) + a.rules.width = a.width + case ScreenSort: + a.sort = newSortModel(a.svc, a.cfg) + a.sort.width, a.sort.height = a.width, a.height + return a, a.sort.Init() + } + return a, nil +} + +// route forwards a message to the active screen. +func (a App) route(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch a.active { + case ScreenMenu: + a.menu, cmd = a.menu.Update(msg) + case ScreenFolders: + a.folders, cmd = a.folders.Update(msg) + case ScreenRules: + a.rules, cmd = a.rules.Update(msg) + case ScreenSort: + a.sort, cmd = a.sort.Update(msg) + } + return a, cmd +} + +// View implements tea.Model. +func (a App) View() string { + switch a.active { + case ScreenFolders: + return a.folders.View() + case ScreenRules: + return a.rules.View() + case ScreenSort: + return a.sort.View() + default: + return a.menu.View() + } +} diff --git a/src/internal/tui/common.go b/src/internal/tui/common.go new file mode 100644 index 0000000..49c96cf --- /dev/null +++ b/src/internal/tui/common.go @@ -0,0 +1,175 @@ +package tui + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +// Screen identifies the active screen. +type Screen int + +// The screens, in menu order. +const ( + ScreenMenu Screen = iota + ScreenFolders + ScreenRules + ScreenSort +) + +// switchMsg asks the root model to change screen. +type switchMsg struct{ screen Screen } + +// switchTo returns a command that changes screen. +func switchTo(s Screen) tea.Cmd { + return func() tea.Msg { return switchMsg{screen: s} } +} + +// statusMsg carries a one-line result back to the menu, such as a save +// confirmation, so an action always produces visible feedback. +type statusMsg struct { + text string + isErr bool +} + +// notify returns a command that shows text on the menu's status line. +func notify(text string, isErr bool) tea.Cmd { + return func() tea.Msg { return statusMsg{text: text, isErr: isErr} } +} + +// ContentWidth converts a terminal width into a usable content width, with a +// sane fallback before the first WindowSizeMsg arrives. +func ContentWidth(termWidth int) int { + if termWidth <= 0 { + return 76 + } + if w := termWidth - 4; w >= 40 { + return w + } + return 40 +} + +// HelpBar renders a footer from key and action pairs, for example +// HelpBar("esc", "back", "q", "quit"). +func HelpBar(pairs ...string) string { + var parts []string + for i := 0; i+1 < len(pairs); i += 2 { + parts = append(parts, StyleField.Render(pairs[i])+" "+pairs[i+1]) + } + return StyleDim.Render(" " + strings.Join(parts, " • ")) +} + +// HelpBarWrap renders the same pairs as HelpBar across as many lines as the +// content width needs, so a screen with many keys stays readable at 80 +// columns instead of wrapping mid-word. +func HelpBarWrap(termWidth int, pairs ...string) string { + limit := ContentWidth(termWidth) + + var lines []string + var current []string + width := 2 // the leading indent + + for i := 0; i+1 < len(pairs); i += 2 { + item := pairs[i] + " " + pairs[i+1] + cost := len([]rune(item)) + if len(current) > 0 { + cost += 5 // the " • " separator + } + if len(current) > 0 && width+cost > limit { + lines = append(lines, renderHelpLine(current)) + current, width = nil, 2 + cost = len([]rune(item)) + } + current = append(current, pairs[i], pairs[i+1]) + width += cost + } + if len(current) > 0 { + lines = append(lines, renderHelpLine(current)) + } + return strings.Join(lines, "\n") +} + +// renderHelpLine formats one line of key and action pairs. +func renderHelpLine(pairs []string) string { + var parts []string + for i := 0; i+1 < len(pairs); i += 2 { + parts = append(parts, StyleField.Render(pairs[i])+" "+pairs[i+1]) + } + return StyleDim.Render(" " + strings.Join(parts, " • ")) +} + +// Truncate shortens s to max runes, ending in a single-character ellipsis. +func Truncate(s string, max int) string { + r := []rune(s) + if max <= 0 || len(r) <= max { + return s + } + if max == 1 { + return "…" + } + return string(r[:max-1]) + "…" +} + +// ProgressBar renders a filled bar with a trailing percentage. +func ProgressBar(done, total, width int) string { + if width < 10 { + width = 10 + } + pct := 0.0 + if total > 0 { + pct = float64(done) / float64(total) + } + filled := int(pct * float64(width)) + if filled > width { + filled = width + } + if filled < 0 { + filled = 0 + } + bar := StyleSuccess.Render(strings.Repeat("█", filled)) + + StyleDim.Render(strings.Repeat("░", width-filled)) + return "[" + bar + "] " + StyleNormal.Render(pctString(pct)) +} + +// pctString renders a fraction as a right-aligned whole percentage. +func pctString(pct float64) string { + n := int(pct*100 + 0.5) + s := "" + switch { + case n < 10: + s = " " + case n < 100: + s = " " + } + return s + itoa(n) + "%" +} + +// itoa converts a small non-negative int to a string without importing strconv +// for a single call site. +func itoa(n int) string { + if n == 0 { + return "0" + } + var b [4]byte + i := len(b) + for n > 0 && i > 0 { + i-- + b[i] = byte('0' + n%10) + n /= 10 + } + return string(b[i:]) +} + +// clampCursor keeps a list cursor inside [0, length). +func clampCursor(cursor, length int) int { + if length <= 0 { + return 0 + } + if cursor < 0 { + return 0 + } + if cursor >= length { + return length - 1 + } + return cursor +} diff --git a/src/internal/tui/common_test.go b/src/internal/tui/common_test.go new file mode 100644 index 0000000..73b0996 --- /dev/null +++ b/src/internal/tui/common_test.go @@ -0,0 +1,163 @@ +package tui + +import ( + "strings" + "testing" +) + +func TestContentWidth(t *testing.T) { + tests := []struct { + in, want int + }{ + {0, 76}, {-5, 76}, {84, 80}, {44, 40}, {20, 40}, {200, 196}, + } + for _, tc := range tests { + if got := ContentWidth(tc.in); got != tc.want { + t.Errorf("ContentWidth(%d) = %d, want %d", tc.in, got, tc.want) + } + } +} + +func TestTruncate(t *testing.T) { + tests := []struct { + in string + max int + want string + }{ + {"hello", 10, "hello"}, + {"hello", 5, "hello"}, + {"hello", 4, "hel…"}, + {"hello", 1, "…"}, + {"hello", 0, "hello"}, + {"héllo wörld", 6, "héllo…"}, + } + for _, tc := range tests { + if got := Truncate(tc.in, tc.max); got != tc.want { + t.Errorf("Truncate(%q, %d) = %q, want %q", tc.in, tc.max, got, tc.want) + } + } +} + +func TestProgressBarBounds(t *testing.T) { + // Must not panic or overflow at the edges, including a zero total. + for _, tc := range []struct{ done, total int }{{0, 0}, {0, 10}, {5, 10}, {10, 10}, {20, 10}, {-1, 10}} { + out := ProgressBar(tc.done, tc.total, 20) + if !strings.Contains(out, "%") { + t.Errorf("ProgressBar(%d, %d) = %q, want a percentage", tc.done, tc.total, out) + } + } +} + +func TestProgressBarPercentages(t *testing.T) { + tests := []struct { + done, total int + want string + }{ + {0, 10, " 0%"}, {5, 10, " 50%"}, {10, 10, "100%"}, {0, 0, " 0%"}, + } + for _, tc := range tests { + if got := ProgressBar(tc.done, tc.total, 20); !strings.Contains(got, tc.want) { + t.Errorf("ProgressBar(%d, %d) = %q, want it to contain %q", tc.done, tc.total, got, tc.want) + } + } +} + +func TestClampCursor(t *testing.T) { + tests := []struct { + cursor, length, want int + }{ + {0, 5, 0}, {4, 5, 4}, {5, 5, 4}, {99, 5, 4}, {-1, 5, 0}, {3, 0, 0}, {0, 0, 0}, + } + for _, tc := range tests { + if got := clampCursor(tc.cursor, tc.length); got != tc.want { + t.Errorf("clampCursor(%d, %d) = %d, want %d", tc.cursor, tc.length, got, tc.want) + } + } +} + +func TestHelpBarPairs(t *testing.T) { + out := HelpBar("esc", "back", "q", "quit") + for _, want := range []string{"esc", "back", "q", "quit", "•"} { + if !strings.Contains(out, want) { + t.Errorf("HelpBar() = %q, want it to contain %q", out, want) + } + } + // An odd trailing key with no action is dropped rather than rendered bare. + if strings.Contains(HelpBar("esc", "back", "x"), "x") { + t.Error("HelpBar() rendered an unpaired key") + } +} + +func TestHelpBarWrapFitsTheWidth(t *testing.T) { + pairs := []string{"↑/↓", "select", "enter", "edit", "a", "add", "d", "delete", + "J/K", "reorder", "t", "toggle default", "r", "reset", "esc", "back"} + + out := HelpBarWrap(84, pairs...) + for _, line := range strings.Split(out, "\n") { + if n := len([]rune(stripANSI(line))); n > 80 { + t.Errorf("help line is %d runes wide, over the 80 content width: %q", n, line) + } + } + // Every key and action must survive the wrap. + for _, want := range pairs { + if !strings.Contains(out, want) { + t.Errorf("HelpBarWrap dropped %q", want) + } + } +} + +func TestHelpBarWrapSingleLineWhenItFits(t *testing.T) { + out := HelpBarWrap(84, "esc", "back", "q", "quit") + if strings.Contains(out, "\n") { + t.Errorf("a short help bar must stay on one line, got %q", out) + } +} + +// stripANSI removes escape sequences so a rendered line can be measured. +func stripANSI(s string) string { + var b strings.Builder + for i := 0; i < len(s); { + if s[i] == 0x1b { + for i < len(s) && s[i] != 'm' { + i++ + } + i++ + continue + } + b.WriteByte(s[i]) + i++ + } + return b.String() +} + +func TestRenderHeaderAndBanner(t *testing.T) { + if out := RenderHeader("Title", "Sub", 84); !strings.Contains(out, "Title") || !strings.Contains(out, "Sub") { + t.Errorf("RenderHeader() = %q, want the title and subtitle", out) + } + if out := RenderHeader("Title", "", 84); !strings.Contains(out, strings.Repeat("═", 80)) { + t.Error("RenderHeader() separator width must follow ContentWidth") + } + // Neither may panic before the first window size arrives. + if out := RenderBanner(0); out == "" { + t.Error("RenderBanner(0) returned nothing") + } +} + +func TestCentreNeverTruncates(t *testing.T) { + long := strings.Repeat("x", 100) + if got := centre(long, 20); got != long { + t.Error("centre() must not alter a string wider than the target width") + } +} + +// TestPaletteContrastRegression pins the two colours whose contrast is closest +// to the WCAG AA floor, so a later palette tweak that fails accessibility +// breaks the build rather than shipping. +func TestPaletteContrastRegression(t *testing.T) { + if string(ColorGray) != "#808080" { + t.Errorf("ColorGray = %s; body text must stay at #808080 (4.6:1) or lighter", ColorGray) + } + if string(ColorDarkGray) == "#444444" { + t.Error("ColorDarkGray is #444444, which fails WCAG AA 3:1 for UI components") + } +} diff --git a/src/internal/tui/folders.go b/src/internal/tui/folders.go new file mode 100644 index 0000000..15241b9 --- /dev/null +++ b/src/internal/tui/folders.go @@ -0,0 +1,135 @@ +package tui + +import ( + "os" + "path/filepath" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/nerdexecutive/ne-image-sorter/internal/domain" +) + +// foldersModel edits the source and destination paths. It carries its own +// minimal line editor rather than a widget dependency, because two single-line +// paths do not justify one. +type foldersModel struct { + cfg domain.Config + field int // 0 source, 1 destination + values [2]string + width int + err string +} + +func newFoldersModel(cfg domain.Config) foldersModel { + return foldersModel{cfg: cfg, values: [2]string{cfg.SourceDir, cfg.DestDir}} +} + +// Update handles path editing. Enter saves and returns to the menu. +func (m foldersModel) Update(msg tea.Msg) (foldersModel, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + + switch keyMsg.String() { + case "esc": + return m, switchTo(ScreenMenu) + case "tab", "down": + m.field = (m.field + 1) % 2 + m.err = "" + case "shift+tab", "up": + m.field = (m.field + 1) % 2 + m.err = "" + case "ctrl+u": + m.values[m.field] = "" + case "backspace": + if r := []rune(m.values[m.field]); len(r) > 0 { + m.values[m.field] = string(r[:len(r)-1]) + } + case "ctrl+d": + // Fill the destination from the source, which is the common shape: + // a rejects folder directly beneath the scanned directory. + if src := strings.TrimSpace(m.values[0]); src != "" { + m.values[1] = filepath.Join(src, "not 16-9 or 16-10 Aspect Ratio") + } + case "enter": + return m.save() + default: + if r := []rune(keyMsg.String()); len(r) == 1 { + m.values[m.field] += string(r) + m.err = "" + } + } + return m, nil +} + +// save validates both paths and, when they hold, hands the config back. +func (m foldersModel) save() (foldersModel, tea.Cmd) { + cfg := m.cfg + cfg.SourceDir = strings.TrimSpace(expandHome(m.values[0])) + cfg.DestDir = strings.TrimSpace(expandHome(m.values[1])) + + if err := cfg.Validate(); err != nil { + m.err = err.Error() + return m, nil + } + if info, err := os.Stat(cfg.SourceDir); err != nil || !info.IsDir() { + m.err = "source directory does not exist: " + cfg.SourceDir + return m, nil + } + + m.cfg = cfg + return m, tea.Batch(saveConfig(cfg), switchTo(ScreenMenu), notify("Folders saved", false)) +} + +// expandHome resolves a leading tilde against the user's home directory. +func expandHome(p string) string { + p = strings.TrimSpace(p) + if p != "~" && !strings.HasPrefix(p, "~/") { + return p + } + home, err := os.UserHomeDir() + if err != nil { + return p + } + return filepath.Join(home, strings.TrimPrefix(strings.TrimPrefix(p, "~"), "/")) +} + +// View renders the two path fields. +func (m foldersModel) View() string { + w := ContentWidth(m.width) + var b strings.Builder + + b.WriteString("\n") + b.WriteString(RenderHeader("Folders", "Where to scan, and where matching images go", m.width)) + b.WriteString("\n\n") + + for i, label := range []string{"Scan this folder", "Move matches into"} { + marker, style := " ", StyleDim + if i == m.field { + marker, style = StyleSelected.Render("▸ "), StyleField + } + b.WriteString(marker + style.Render(label) + "\n") + + value := m.values[i] + shown := Truncate(value, w-8) + if i == m.field { + shown += StyleAccent.Render("█") + } else if strings.TrimSpace(value) == "" { + shown = StyleDim.Render("(not set)") + } + b.WriteString(" " + StyleNormal.Render(shown) + "\n") + b.WriteString(" " + StyleSeparator.Render(strings.Repeat("─", w-4)) + "\n\n") + } + + b.WriteString(" " + StyleDim.Render("Destination is created on the first move if it is missing.") + "\n") + if m.err != "" { + b.WriteString("\n " + StyleError.Render(m.err) + "\n") + } + b.WriteString("\n") + b.WriteString(HelpBarWrap(m.width, "tab", "switch field", "ctrl+d", "default destination", + "ctrl+u", "clear", "enter", "save", "esc", "cancel")) + b.WriteString("\n") + return b.String() +} diff --git a/src/internal/tui/helpers_test.go b/src/internal/tui/helpers_test.go new file mode 100644 index 0000000..059f243 --- /dev/null +++ b/src/internal/tui/helpers_test.go @@ -0,0 +1,78 @@ +package tui + +import ( + "io" + + tea "github.com/charmbracelet/bubbletea" + "github.com/rs/zerolog" + + "github.com/nerdexecutive/ne-image-sorter/internal/domain" + "github.com/nerdexecutive/ne-image-sorter/internal/sorter" +) + +// key builds a KeyMsg for a named key or a single rune. +func key(s string) tea.KeyMsg { + switch s { + case "enter": + return tea.KeyMsg{Type: tea.KeyEnter} + case "esc": + return tea.KeyMsg{Type: tea.KeyEsc} + case "up": + return tea.KeyMsg{Type: tea.KeyUp} + case "down": + return tea.KeyMsg{Type: tea.KeyDown} + case "left": + return tea.KeyMsg{Type: tea.KeyLeft} + case "right": + return tea.KeyMsg{Type: tea.KeyRight} + case "tab": + return tea.KeyMsg{Type: tea.KeyTab} + case "backspace": + return tea.KeyMsg{Type: tea.KeyBackspace} + case "ctrl+d": + return tea.KeyMsg{Type: tea.KeyCtrlD} + case "ctrl+u": + return tea.KeyMsg{Type: tea.KeyCtrlU} + default: + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} + } +} + +// typeRules feeds each rune of s to a rules editor. +func typeRules(m rulesModel, s string) rulesModel { + for _, r := range s { + m, _ = m.Update(key(string(r))) + } + return m +} + +// readyConfig is a configuration that passes validation. +func readyConfig() domain.Config { + cfg := domain.DefaultConfig() + cfg.SourceDir = "/pics" + cfg.DestDir = "/pics/rejects" + return cfg +} + +// stubImages returns a fixed image list, so the sort screen is driven without +// a filesystem. +type stubImages struct{ images []domain.Image } + +func (s stubImages) List(string, []string) ([]domain.Image, error) { return s.images, nil } +func (s stubImages) Move(domain.Image, string) (string, error) { return "", nil } + +// sortScreen builds a sort screen over a fixed image list. +func sortScreen(images ...domain.Image) sortModel { + svc := sorter.New(stubImages{images: images}, zerolog.New(io.Discard)) + m := newSortModel(svc, readyConfig()) + m.width, m.height = 100, 40 + return m +} + +// runInit executes the screen's Init command and feeds the result back, which +// is what the Bubbletea runtime does. +func runInit(m sortModel) sortModel { + msg := m.Init()() + m, _ = m.Update(msg) + return m +} diff --git a/src/internal/tui/menu.go b/src/internal/tui/menu.go new file mode 100644 index 0000000..c9b2c79 --- /dev/null +++ b/src/internal/tui/menu.go @@ -0,0 +1,123 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/nerdexecutive/ne-image-sorter/internal/domain" +) + +type menuItem struct { + label string + desc string + screen Screen + quit bool +} + +var menuItems = []menuItem{ + {label: "Sort Images", desc: "Scan the source, preview every decision, then move", screen: ScreenSort}, + {label: "Folders", desc: "Choose the directory to scan and the one to move into", screen: ScreenFolders}, + {label: "Rules", desc: "Edit the ordered aspect ratio and resolution policy", screen: ScreenRules}, + {label: "Quit", desc: "Exit ne-image-sorter", quit: true}, +} + +type menuModel struct { + cursor int + width int + cfg domain.Config + status string + isErr bool +} + +func newMenuModel(cfg domain.Config) menuModel { + return menuModel{cfg: cfg} +} + +// Update handles menu input. +func (m menuModel) Update(msg tea.Msg) (menuModel, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch keyMsg.String() { + case "up", "k": + m.cursor = clampCursor(m.cursor-1, len(menuItems)) + case "down", "j": + m.cursor = clampCursor(m.cursor+1, len(menuItems)) + case "enter": + item := menuItems[m.cursor] + if item.quit { + return m, tea.Quit + } + m.status = "" + return m, switchTo(item.screen) + case "q": + return m, tea.Quit + } + return m, nil +} + +// View renders the main menu with the current configuration summary. +func (m menuModel) View() string { + var b strings.Builder + b.WriteString("\n") + b.WriteString(RenderBanner(m.width)) + b.WriteString("\n") + b.WriteString(StyleSubtitle.Render(" Sort images by aspect ratio and resolution")) + b.WriteString("\n\n") + + b.WriteString(m.summary()) + b.WriteString("\n") + + for i, item := range menuItems { + cursor, style := " ", StyleNormal + if i == m.cursor { + cursor, style = StyleSelected.Render("▸ "), StyleSelected + } + b.WriteString(cursor + style.Render(item.label) + "\n") + b.WriteString(" " + StyleDim.Render(item.desc) + "\n\n") + } + + if m.status != "" { + style := StyleSuccess + if m.isErr { + style = StyleError + } + b.WriteString(" " + style.Render(m.status) + "\n\n") + } + + b.WriteString(HelpBar("↑/↓", "navigate", "enter", "select", "q", "quit")) + b.WriteString("\n") + return b.String() +} + +// summary shows the configured directories and rule count, and warns when the +// tool is not yet ready to run. +func (m menuModel) summary() string { + w := ContentWidth(m.width) + var b strings.Builder + + b.WriteString(" " + StyleDim.Render("source") + " " + + StyleNormal.Render(Truncate(orUnset(m.cfg.SourceDir), w-12)) + "\n") + b.WriteString(" " + StyleDim.Render("move to") + " " + + StyleNormal.Render(Truncate(orUnset(m.cfg.DestDir), w-12)) + "\n") + b.WriteString(" " + StyleDim.Render("rules") + " " + + StyleNormal.Render(fmt.Sprintf("%d, then %s everything else", + len(m.cfg.Policy.Rules), m.cfg.Policy.Default)) + "\n") + + if err := m.cfg.Validate(); err != nil { + b.WriteString("\n " + StyleWarning.Render("Not ready: "+err.Error()) + "\n") + b.WriteString(" " + StyleDim.Render("Open Folders to choose a source and a destination.") + "\n") + } + return b.String() +} + +// orUnset renders an empty path as a visible placeholder. +func orUnset(s string) string { + if strings.TrimSpace(s) == "" { + return "(not set)" + } + return s +} diff --git a/src/internal/tui/menu_test.go b/src/internal/tui/menu_test.go new file mode 100644 index 0000000..d47d2a5 --- /dev/null +++ b/src/internal/tui/menu_test.go @@ -0,0 +1,138 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/nerdexecutive/ne-image-sorter/internal/domain" +) + +func TestMenuNavigationStaysInBounds(t *testing.T) { + m := newMenuModel(readyConfig()) + for i := 0; i < 20; i++ { + m, _ = m.Update(key("down")) + } + if m.cursor != len(menuItems)-1 { + t.Errorf("cursor = %d after over-scrolling, want %d", m.cursor, len(menuItems)-1) + } + for i := 0; i < 20; i++ { + m, _ = m.Update(key("up")) + } + if m.cursor != 0 { + t.Errorf("cursor = %d after over-scrolling up, want 0", m.cursor) + } +} + +func TestMenuWarnsWhenNotConfigured(t *testing.T) { + unset := newMenuModel(domain.DefaultConfig()).View() + if !strings.Contains(unset, "Not ready") { + t.Error("menu must warn when the directories are unset") + } + if !strings.Contains(unset, "(not set)") { + t.Error("menu must render unset paths as a visible placeholder") + } + if ready := newMenuModel(readyConfig()).View(); strings.Contains(ready, "Not ready") { + t.Error("menu must not warn when the config is valid") + } +} + +func TestMenuQuitKey(t *testing.T) { + if _, cmd := newMenuModel(readyConfig()).Update(key("q")); cmd == nil { + t.Error("q produced no command, want quit") + } +} + +func TestMenuEnterOpensTheSelectedScreen(t *testing.T) { + m := newMenuModel(readyConfig()) + if _, cmd := m.Update(key("enter")); cmd == nil { + t.Fatal("enter produced no command") + } else if msg, ok := cmd().(switchMsg); !ok || msg.screen != ScreenSort { + t.Errorf("enter on the first item = %v, want a switch to the sort screen", cmd()) + } +} + +func TestFoldersEditingAndDefaultDestination(t *testing.T) { + m := newFoldersModel(domain.DefaultConfig()) + for _, r := range "/tmp/pics" { + m, _ = m.Update(key(string(r))) + } + if m.values[0] != "/tmp/pics" { + t.Fatalf("source = %q, want /tmp/pics", m.values[0]) + } + + m, _ = m.Update(key("backspace")) + if m.values[0] != "/tmp/pic" { + t.Errorf("backspace left %q", m.values[0]) + } + + m, _ = m.Update(key("ctrl+d")) + if !strings.HasPrefix(m.values[1], "/tmp/pic/") { + t.Errorf("ctrl+d destination = %q, want it under the source", m.values[1]) + } + + m, _ = m.Update(key("ctrl+u")) + if m.values[0] != "" { + t.Errorf("ctrl+u left %q, want an empty field", m.values[0]) + } +} + +func TestFoldersTabSwitchesField(t *testing.T) { + m := newFoldersModel(domain.DefaultConfig()) + if m.field != 0 { + t.Fatal("folders must open on the source field") + } + m, _ = m.Update(key("tab")) + if m.field != 1 { + t.Errorf("field = %d after tab, want 1", m.field) + } + m, _ = m.Update(key("tab")) + if m.field != 0 { + t.Errorf("field = %d after a second tab, want 0", m.field) + } +} + +func TestFoldersRejectsAMissingSource(t *testing.T) { + m := newFoldersModel(domain.DefaultConfig()) + m.values = [2]string{"/definitely/not/here", "/definitely/not/here/sub"} + m, cmd := m.Update(key("enter")) + if cmd != nil { + t.Error("enter must not save when the source does not exist") + } + if !strings.Contains(m.err, "does not exist") { + t.Errorf("err = %q, want it to name the missing directory", m.err) + } +} + +func TestFoldersRejectsIdenticalDirectories(t *testing.T) { + m := newFoldersModel(domain.DefaultConfig()) + m.values = [2]string{"/tmp", "/tmp"} + m, cmd := m.Update(key("enter")) + if cmd != nil || m.err == "" { + t.Error("enter must refuse a destination identical to the source") + } +} + +func TestFoldersSavesAValidPair(t *testing.T) { + dir := t.TempDir() + m := newFoldersModel(domain.DefaultConfig()) + m.values = [2]string{dir, dir + "/rejects"} + m, cmd := m.Update(key("enter")) + if cmd == nil { + t.Fatalf("enter on a valid pair produced no command, err = %q", m.err) + } + if m.cfg.SourceDir != dir { + t.Errorf("saved source = %q, want %q", m.cfg.SourceDir, dir) + } +} + +func TestExpandHome(t *testing.T) { + if got := expandHome("/absolute/path"); got != "/absolute/path" { + t.Errorf("expandHome left an absolute path as %q", got) + } + if got := expandHome("~/pics"); strings.HasPrefix(got, "~") { + t.Errorf("expandHome(~/pics) = %q, want the tilde resolved", got) + } + if got := expandHome("./relative"); got != "./relative" { + t.Errorf("expandHome altered a relative path to %q", got) + } +} diff --git a/src/internal/tui/rules.go b/src/internal/tui/rules.go new file mode 100644 index 0000000..fc72716 --- /dev/null +++ b/src/internal/tui/rules.go @@ -0,0 +1,414 @@ +package tui + +import ( + "fmt" + "strconv" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/nerdexecutive/ne-image-sorter/internal/domain" +) + +// rulesMode is the rules screen's sub-state: browsing the ordered list, or +// editing one rule's fields. +type rulesMode int + +const ( + modeList rulesMode = iota + modeEdit +) + +// editField indexes the rule editor's rows. +type editField int + +const ( + fieldProperty editField = iota + fieldOperator + fieldValue + fieldTolerance + fieldAction + fieldCount +) + +var editLabels = [fieldCount]string{"Property", "Comparison", "Value", "Tolerance", "Then"} + +// rulesModel edits the ordered policy. +type rulesModel struct { + cfg domain.Config + cursor int + mode rulesMode + editing domain.Rule + editIdx int // -1 when adding + editRow editField + valueBuf string + tolBuf string + width int + err string +} + +func newRulesModel(cfg domain.Config) rulesModel { + return rulesModel{cfg: cfg, editIdx: -1} +} + +// Update routes input to the list or the editor. +func (m rulesModel) Update(msg tea.Msg) (rulesModel, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + if m.mode == modeEdit { + return m.updateEdit(keyMsg) + } + return m.updateList(keyMsg) +} + +// updateList handles browsing, reordering, deleting, and the default action. +func (m rulesModel) updateList(k tea.KeyMsg) (rulesModel, tea.Cmd) { + rules := m.cfg.Policy.Rules + switch k.String() { + case "esc": + return m, switchTo(ScreenMenu) + case "up", "k": + m.cursor = clampCursor(m.cursor-1, len(rules)) + case "down", "j": + m.cursor = clampCursor(m.cursor+1, len(rules)) + case "K": + m = m.swap(m.cursor, m.cursor-1) + case "J": + m = m.swap(m.cursor, m.cursor+1) + case "a": + m.mode, m.editIdx, m.editRow = modeEdit, -1, fieldProperty + m.editing = domain.Rule{Field: domain.FieldAspect, Op: domain.OpEqual, Action: domain.ActionKeep} + m.valueBuf, m.tolBuf, m.err = "", "1", "" + case "enter": + if len(rules) == 0 { + return m, nil + } + m.mode, m.editIdx, m.editRow = modeEdit, m.cursor, fieldProperty + m.editing = rules[m.cursor] + m.valueBuf = m.editing.Label + if m.valueBuf == "" { + m.valueBuf = trimFloat(m.editing.Value) + } + m.tolBuf, m.err = trimFloat(m.editing.Tolerance*100), "" + case "d": + if len(rules) > 0 { + m.cfg.Policy.Rules = append(append([]domain.Rule{}, rules[:m.cursor]...), rules[m.cursor+1:]...) + m.cursor = clampCursor(m.cursor, len(m.cfg.Policy.Rules)) + return m, saveConfig(m.cfg) + } + case "t": + // Toggle the fallback that applies when no rule matches. + if m.cfg.Policy.Default == domain.ActionMove { + m.cfg.Policy.Default = domain.ActionKeep + } else { + m.cfg.Policy.Default = domain.ActionMove + } + return m, saveConfig(m.cfg) + case "r": + m.cfg.Policy = domain.DefaultPolicy() + m.cursor = 0 + return m, saveConfig(m.cfg) + } + return m, nil +} + +// swap moves the rule at i to position j, which changes which rule decides +// first and so can change the outcome. +func (m rulesModel) swap(i, j int) rulesModel { + rules := m.cfg.Policy.Rules + if i < 0 || j < 0 || i >= len(rules) || j >= len(rules) { + return m + } + rules[i], rules[j] = rules[j], rules[i] + m.cursor = j + return m +} + +// updateEdit handles the rule editor. +func (m rulesModel) updateEdit(k tea.KeyMsg) (rulesModel, tea.Cmd) { + switch k.String() { + case "esc": + m.mode, m.err = modeList, "" + return m, nil + case "up": + m.editRow = editField(clampCursor(int(m.editRow)-1, int(fieldCount))) + case "down", "tab": + m.editRow = editField(clampCursor(int(m.editRow)+1, int(fieldCount))) + case "left": + m = m.cycle(-1) + case "right": + m = m.cycle(1) + case "backspace": + m = m.editText(func(s string) string { + if r := []rune(s); len(r) > 0 { + return string(r[:len(r)-1]) + } + return s + }) + case "enter": + return m.commit() + default: + if r := []rune(k.String()); len(r) == 1 { + m = m.editText(func(s string) string { return s + string(r) }) + } + } + return m, nil +} + +// cycle steps an enumerated field left or right. +func (m rulesModel) cycle(delta int) rulesModel { + switch m.editRow { + case fieldProperty: + fields := domain.Fields() + m.editing.Field = fields[wrap(indexOfField(fields, m.editing.Field)+delta, len(fields))] + case fieldOperator: + ops := domain.Operators() + m.editing.Op = ops[wrap(indexOfOp(ops, m.editing.Op)+delta, len(ops))] + case fieldAction: + if m.editing.Action == domain.ActionMove { + m.editing.Action = domain.ActionKeep + } else { + m.editing.Action = domain.ActionMove + } + } + m.err = "" + return m +} + +// editText applies edit to whichever text buffer the cursor is on. +func (m rulesModel) editText(edit func(string) string) rulesModel { + switch m.editRow { + case fieldValue: + m.valueBuf = edit(m.valueBuf) + case fieldTolerance: + m.tolBuf = edit(m.tolBuf) + default: + return m + } + m.err = "" + return m +} + +// commit parses the buffers into the rule and stores it. +func (m rulesModel) commit() (rulesModel, tea.Cmd) { + rule := m.editing + + if rule.Field == domain.FieldAspect { + v, err := domain.ParseRatio(m.valueBuf) + if err != nil { + m.err = "value must be a ratio such as 16:9, or a decimal such as 1.7778" + return m, nil + } + rule.Value = v + rule.Label = strings.TrimSpace(m.valueBuf) + if !strings.ContainsAny(rule.Label, ":/") { + rule.Label = "" + } + } else { + v, err := strconv.ParseFloat(strings.TrimSpace(m.valueBuf), 64) + if err != nil || v < 0 { + m.err = "value must be a pixel count, such as 1080" + return m, nil + } + rule.Value, rule.Label = v, "" + } + + pct, err := strconv.ParseFloat(strings.TrimSpace(orZero(m.tolBuf)), 64) + if err != nil || pct < 0 { + m.err = "tolerance must be a percentage, such as 1 or 2.5" + return m, nil + } + rule.Tolerance = pct / 100 + + if m.editIdx < 0 { + m.cfg.Policy.Rules = append(m.cfg.Policy.Rules, rule) + m.cursor = len(m.cfg.Policy.Rules) - 1 + } else { + m.cfg.Policy.Rules[m.editIdx] = rule + } + m.mode, m.err = modeList, "" + return m, saveConfig(m.cfg) +} + +// View renders the list or the editor. +func (m rulesModel) View() string { + if m.mode == modeEdit { + return m.viewEdit() + } + return m.viewList() +} + +// viewList renders the ordered policy. +func (m rulesModel) viewList() string { + w := ContentWidth(m.width) + var b strings.Builder + + b.WriteString("\n") + b.WriteString(RenderHeader("Rules", "Checked top to bottom. The first match decides.", m.width)) + b.WriteString("\n\n") + + if len(m.cfg.Policy.Rules) == 0 { + b.WriteString(" " + StyleWarning.Render("No rules. Every image falls through to the default below.") + "\n") + b.WriteString(" " + StyleDim.Render("Press a to add one, or r to restore the shipped policy.") + "\n\n") + } + + for i, rule := range m.cfg.Policy.Rules { + marker, style := " ", StyleNormal + if i == m.cursor { + marker, style = StyleSelected.Render("▸ "), StyleSelected + } + b.WriteString(fmt.Sprintf("%s%s %s\n", marker, + StyleDim.Render(fmt.Sprintf("%d.", i+1)), + style.Render(Truncate(rule.Describe(), w-8)))) + } + + b.WriteString("\n " + StyleSeparator.Render(strings.Repeat("─", w-2)) + "\n") + b.WriteString(" " + StyleDim.Render("otherwise") + " " + + actionStyle(m.cfg.Policy.Default).Render(string(m.cfg.Policy.Default)) + "\n\n") + + b.WriteString(HelpBarWrap(m.width, "↑/↓", "select", "enter", "edit", "a", "add", + "d", "delete", "J/K", "reorder", "t", "toggle default", "r", "reset", "esc", "back")) + b.WriteString("\n") + return b.String() +} + +// viewEdit renders the single-rule editor. +func (m rulesModel) viewEdit() string { + w := ContentWidth(m.width) + title := "Edit Rule" + if m.editIdx < 0 { + title = "Add Rule" + } + + var b strings.Builder + b.WriteString("\n") + b.WriteString(RenderHeader(title, "← → change a choice, type to edit a value", m.width)) + b.WriteString("\n\n") + + values := [fieldCount]string{ + string(m.editing.Field), + string(m.editing.Op) + " " + StyleDim.Render(opHint(m.editing.Op)), + valueOrHint(m.valueBuf, m.editing.Field) + StyleAccent.Render(cursorIf(m.editRow == fieldValue)), + m.tolBuf + "%" + StyleAccent.Render(cursorIf(m.editRow == fieldTolerance)), + string(m.editing.Action) + " " + StyleDim.Render(actionHint(m.editing.Action)), + } + + for i := range values { + marker, label := " ", StyleDim + if editField(i) == m.editRow { + marker, label = StyleSelected.Render("▸ "), StyleField + } + b.WriteString(fmt.Sprintf("%s%s %s\n", marker, + label.Render(fmt.Sprintf("%-11s", editLabels[i])), values[i])) + } + + b.WriteString("\n " + StyleSeparator.Render(strings.Repeat("─", w-2)) + "\n") + if m.editing.Field == domain.FieldAspect { + b.WriteString(" " + StyleDim.Render("Ratios accept 16:9, 16/10, or a decimal like 1.7778.") + "\n") + } else { + b.WriteString(" " + StyleDim.Render("Values are pixels. Tolerance applies to == and != only.") + "\n") + } + if m.err != "" { + b.WriteString("\n " + StyleError.Render(m.err) + "\n") + } + + b.WriteString("\n") + b.WriteString(HelpBar("↑/↓", "field", "←/→", "change", "enter", "save", "esc", "cancel")) + b.WriteString("\n") + return b.String() +} + +// opHint explains an operator in words. +func opHint(op domain.Operator) string { + switch op { + case domain.OpEqual: + return "matches, within tolerance" + case domain.OpNotEqual: + return "differs, beyond tolerance" + case domain.OpLess: + return "is under" + case domain.OpLessEqual: + return "is at most" + case domain.OpGreater: + return "is over" + case domain.OpGreaterEqual: + return "is at least" + default: + return "" + } +} + +// actionHint explains an action in words. +func actionHint(a domain.Action) string { + if a == domain.ActionMove { + return "relocate to the destination folder" + } + return "leave in the source folder" +} + +// actionStyle colours a verdict: green keeps, yellow moves. +func actionStyle(a domain.Action) interface{ Render(...string) string } { + if a == domain.ActionMove { + return StyleWarning + } + return StyleSuccess +} + +// valueOrHint renders the typed value, or a dim example when it is empty, so +// the field never reads as broken. +func valueOrHint(buf string, field domain.Field) string { + if strings.TrimSpace(buf) != "" { + return buf + } + if field == domain.FieldAspect { + return StyleDim.Render("16:9") + } + return StyleDim.Render("1080") +} + +// cursorIf returns a block cursor when the field has focus. +func cursorIf(focused bool) string { + if focused { + return "█" + } + return "" +} + +// trimFloat renders a float without trailing zeros. +func trimFloat(v float64) string { return strconv.FormatFloat(v, 'f', -1, 64) } + +// orZero substitutes "0" for an empty buffer. +func orZero(s string) string { + if strings.TrimSpace(s) == "" { + return "0" + } + return s +} + +// wrap returns i modulo n, wrapping negatives around. +func wrap(i, n int) int { + if n <= 0 { + return 0 + } + return ((i % n) + n) % n +} + +func indexOfField(fields []domain.Field, f domain.Field) int { + for i, v := range fields { + if v == f { + return i + } + } + return 0 +} + +func indexOfOp(ops []domain.Operator, o domain.Operator) int { + for i, v := range ops { + if v == o { + return i + } + } + return 0 +} diff --git a/src/internal/tui/rules_test.go b/src/internal/tui/rules_test.go new file mode 100644 index 0000000..4ff92c7 --- /dev/null +++ b/src/internal/tui/rules_test.go @@ -0,0 +1,254 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/nerdexecutive/ne-image-sorter/internal/domain" +) + +func TestRulesReorderChangesPrecedence(t *testing.T) { + m := newRulesModel(readyConfig()) + first := m.cfg.Policy.Rules[0] + + m, _ = m.Update(key("J")) // move rule 1 down + if m.cfg.Policy.Rules[1] != first { + t.Error("J must move the selected rule down one place") + } + if m.cursor != 1 { + t.Errorf("cursor = %d after J, want it to follow the rule to 1", m.cursor) + } + + m, _ = m.Update(key("K")) + if m.cfg.Policy.Rules[0] != first { + t.Error("K must move the selected rule back up") + } +} + +func TestRulesReorderAtTheEdgesIsANoOp(t *testing.T) { + m := newRulesModel(readyConfig()) + before := len(m.cfg.Policy.Rules) + m, _ = m.Update(key("K")) // already at the top + if len(m.cfg.Policy.Rules) != before || m.cursor != 0 { + t.Error("K at the top must change nothing") + } +} + +func TestRulesDeleteAndReset(t *testing.T) { + m := newRulesModel(readyConfig()) + original := len(m.cfg.Policy.Rules) + + m, cmd := m.Update(key("d")) + if cmd == nil { + t.Error("delete must persist the change") + } + if len(m.cfg.Policy.Rules) != original-1 { + t.Fatalf("after delete there are %d rules, want %d", len(m.cfg.Policy.Rules), original-1) + } + + m, _ = m.Update(key("r")) + if len(m.cfg.Policy.Rules) != original { + t.Errorf("reset gave %d rules, want the shipped %d", len(m.cfg.Policy.Rules), original) + } +} + +func TestRulesToggleDefault(t *testing.T) { + m := newRulesModel(readyConfig()) + if m.cfg.Policy.Default != domain.ActionMove { + t.Fatal("the shipped default must be move") + } + m, _ = m.Update(key("t")) + if m.cfg.Policy.Default != domain.ActionKeep { + t.Errorf("default = %v after toggle, want keep", m.cfg.Policy.Default) + } + m, _ = m.Update(key("t")) + if m.cfg.Policy.Default != domain.ActionMove { + t.Error("toggling twice must return to move") + } +} + +func TestRulesAddARatioRule(t *testing.T) { + m := newRulesModel(readyConfig()) + original := len(m.cfg.Policy.Rules) + + m, _ = m.Update(key("a")) + if m.mode != modeEdit || m.editIdx != -1 { + t.Fatal("a must open the editor in add mode") + } + + // Property defaults to aspect. Move to the value row and type a ratio. + m, _ = m.Update(key("down")) + m, _ = m.Update(key("down")) + m = typeRules(m, "21:9") + m, cmd := m.Update(key("enter")) + + if cmd == nil { + t.Fatalf("enter did not commit, err = %q", m.err) + } + if len(m.cfg.Policy.Rules) != original+1 { + t.Fatalf("rule count = %d, want %d", len(m.cfg.Policy.Rules), original+1) + } + added := m.cfg.Policy.Rules[original] + if added.Label != "21:9" { + t.Errorf("label = %q, want the typed ratio preserved", added.Label) + } + if diff := added.Value - 21.0/9.0; diff > 1e-9 || diff < -1e-9 { + t.Errorf("value = %v, want 21/9", added.Value) + } + if added.Tolerance != 0.01 { + t.Errorf("tolerance = %v, want the 1%% default", added.Tolerance) + } +} + +func TestRulesRejectsABadRatio(t *testing.T) { + m := newRulesModel(readyConfig()) + m, _ = m.Update(key("a")) + m, _ = m.Update(key("down")) + m, _ = m.Update(key("down")) + m = typeRules(m, "wide") + m, cmd := m.Update(key("enter")) + + if cmd != nil { + t.Error("a bad ratio must not commit") + } + if !strings.Contains(m.err, "16:9") { + t.Errorf("err = %q, want it to suggest a valid form", m.err) + } +} + +func TestRulesRejectsABadTolerance(t *testing.T) { + m := newRulesModel(readyConfig()) + m, _ = m.Update(key("a")) + m, _ = m.Update(key("down")) + m, _ = m.Update(key("down")) + m = typeRules(m, "16:9") + m.tolBuf = "abc" + m, cmd := m.Update(key("enter")) + + if cmd != nil || !strings.Contains(m.err, "percentage") { + t.Errorf("a bad tolerance must be refused, got cmd=%v err=%q", cmd, m.err) + } +} + +func TestRulesEmptyToleranceMeansExact(t *testing.T) { + m := newRulesModel(readyConfig()) + m, _ = m.Update(key("a")) + m, _ = m.Update(key("down")) + m, _ = m.Update(key("down")) + m = typeRules(m, "16:9") + m.tolBuf = "" + m, cmd := m.Update(key("enter")) + + if cmd == nil { + t.Fatalf("an empty tolerance must commit as exact, err = %q", m.err) + } + if added := m.cfg.Policy.Rules[len(m.cfg.Policy.Rules)-1]; added.Tolerance != 0 { + t.Errorf("tolerance = %v, want 0", added.Tolerance) + } +} + +func TestRulesCyclingWrapsBothWays(t *testing.T) { + m := newRulesModel(readyConfig()) + m, _ = m.Update(key("a")) + + fields := domain.Fields() + for i := 0; i < len(fields); i++ { + m, _ = m.Update(key("right")) + } + if m.editing.Field != fields[0] { + t.Errorf("cycling a full turn gave %v, want %v", m.editing.Field, fields[0]) + } + + m, _ = m.Update(key("left")) + if m.editing.Field != fields[len(fields)-1] { + t.Errorf("cycling left from the first gave %v, want the last", m.editing.Field) + } +} + +func TestRulesPixelRuleTakesAWholeNumber(t *testing.T) { + m := newRulesModel(readyConfig()) + m, _ = m.Update(key("a")) + m, _ = m.Update(key("right")) // aspect -> width + m, _ = m.Update(key("right")) // width -> height + m, _ = m.Update(key("down")) // operator row + m, _ = m.Update(key("right")) // == -> != + m, _ = m.Update(key("down")) // value row + m = typeRules(m, "1440") + m, cmd := m.Update(key("enter")) + + if cmd == nil { + t.Fatalf("a pixel rule did not commit, err = %q", m.err) + } + added := m.cfg.Policy.Rules[len(m.cfg.Policy.Rules)-1] + if added.Field != domain.FieldHeight || added.Value != 1440 { + t.Errorf("added %+v, want height 1440", added) + } + if added.Label != "" { + t.Errorf("label = %q, want none on a pixel rule", added.Label) + } +} + +func TestRulesEscapeLeavesTheEditorWithoutSaving(t *testing.T) { + m := newRulesModel(readyConfig()) + original := len(m.cfg.Policy.Rules) + m, _ = m.Update(key("a")) + m, _ = m.Update(key("esc")) + if m.mode != modeList { + t.Error("esc must return to the list") + } + if len(m.cfg.Policy.Rules) != original { + t.Error("esc must not add the half-built rule") + } +} + +func TestRulesEditPreservesTheRatioLabel(t *testing.T) { + m := newRulesModel(readyConfig()) + m.cursor = 1 // the 16:9 keep rule + m, _ = m.Update(key("enter")) + if m.valueBuf != "16:9" { + t.Errorf("editor opened with value %q, want the stored label 16:9", m.valueBuf) + } + if m.tolBuf != "1" { + t.Errorf("editor opened with tolerance %q, want 1", m.tolBuf) + } +} + +func TestRulesViewRendersEveryRuleAndTheDefault(t *testing.T) { + out := newRulesModel(readyConfig()).viewList() + for _, want := range []string{"1080", "16:9", "16:10", "otherwise", "move"} { + if !strings.Contains(out, want) { + t.Errorf("rules list is missing %q", want) + } + } +} + +func TestRulesEmptyListRenders(t *testing.T) { + cfg := readyConfig() + cfg.Policy.Rules = nil + out := newRulesModel(cfg).viewList() + if !strings.Contains(out, "No rules") { + t.Error("an empty policy must render an empty state, not a blank screen") + } +} + +func TestRulesEditorViewRenders(t *testing.T) { + m := newRulesModel(readyConfig()) + m, _ = m.Update(key("a")) + out := m.View() + for _, want := range []string{"Add Rule", "Property", "Comparison", "Tolerance", "aspect"} { + if !strings.Contains(out, want) { + t.Errorf("rule editor is missing %q", want) + } + } +} + +func TestWrap(t *testing.T) { + tests := []struct{ i, n, want int }{ + {0, 3, 0}, {2, 3, 2}, {3, 3, 0}, {-1, 3, 2}, {-4, 3, 2}, {1, 0, 0}, + } + for _, tc := range tests { + if got := wrap(tc.i, tc.n); got != tc.want { + t.Errorf("wrap(%d, %d) = %d, want %d", tc.i, tc.n, got, tc.want) + } + } +} diff --git a/src/internal/tui/sort.go b/src/internal/tui/sort.go new file mode 100644 index 0000000..dd5c86f --- /dev/null +++ b/src/internal/tui/sort.go @@ -0,0 +1,279 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/nerdexecutive/ne-image-sorter/internal/domain" + "github.com/nerdexecutive/ne-image-sorter/internal/sorter" +) + +// sortStage is the sort screen's position in the scan, confirm, apply cycle. +type sortStage int + +const ( + stageScanning sortStage = iota + stagePreview + stageConfirm + stageDone + stageFailed +) + +// planMsg carries a completed scan back to the screen. +type planMsg struct { + plan sorter.Plan + err error +} + +// reportMsg carries a completed move run back to the screen. +type reportMsg struct { + report sorter.Report + err error +} + +// sortModel previews the policy against the source folder, then applies it. +type sortModel struct { + svc *sorter.Service + cfg domain.Config + stage sortStage + plan sorter.Plan + report sorter.Report + cursor int + showKeep bool + width int + height int + err string +} + +func newSortModel(svc *sorter.Service, cfg domain.Config) sortModel { + return sortModel{svc: svc, cfg: cfg, stage: stageScanning} +} + +// Init starts the scan as soon as the screen opens. +func (m sortModel) Init() tea.Cmd { + return func() tea.Msg { + plan, err := m.svc.Plan(m.cfg) + return planMsg{plan: plan, err: err} + } +} + +// Update advances the scan, confirm, apply cycle. +func (m sortModel) Update(msg tea.Msg) (sortModel, tea.Cmd) { + switch msg := msg.(type) { + case planMsg: + if msg.err != nil { + m.stage, m.err = stageFailed, msg.err.Error() + return m, nil + } + m.plan, m.stage, m.cursor = msg.plan, stagePreview, 0 + return m, nil + + case reportMsg: + if msg.err != nil { + m.stage, m.err = stageFailed, msg.err.Error() + return m, nil + } + m.report, m.stage = msg.report, stageDone + return m, nil + + case tea.KeyMsg: + return m.updateKey(msg) + } + return m, nil +} + +// updateKey handles input for whichever stage is active. +func (m sortModel) updateKey(k tea.KeyMsg) (sortModel, tea.Cmd) { + key := k.String() + + // esc leaves from every stage, which keeps its meaning constant. + if key == "esc" { + return m, switchTo(ScreenMenu) + } + + switch m.stage { + case stagePreview: + switch key { + case "up", "k": + m.cursor = clampCursor(m.cursor-1, len(m.visible())) + case "down", "j": + m.cursor = clampCursor(m.cursor+1, len(m.visible())) + case "v": + m.showKeep = !m.showKeep + m.cursor = 0 + case "enter": + if m.plan.MoveCount > 0 { + m.stage = stageConfirm + } + } + + case stageConfirm: + switch key { + case "y": + m.stage = stageScanning + cfg, svc, plan := m.cfg, m.svc, m.plan + return m, func() tea.Msg { + report, err := svc.Apply(cfg, plan) + return reportMsg{report: report, err: err} + } + case "n": + m.stage = stagePreview + } + + case stageDone, stageFailed: + if key == "enter" || key == "q" { + return m, switchTo(ScreenMenu) + } + } + return m, nil +} + +// visible returns the decisions the current filter shows. +func (m sortModel) visible() []sorter.Decision { + if m.showKeep { + return m.plan.Decisions + } + return m.plan.Moves() +} + +// View renders the active stage. +func (m sortModel) View() string { + switch m.stage { + case stageScanning: + return m.frame("Working", StyleDim.Render(" Reading image headers…"), + HelpBar("esc", "back")) + case stageConfirm: + return m.viewConfirm() + case stageDone: + return m.viewDone() + case stageFailed: + return m.frame("Sort failed", " "+StyleError.Render(m.err)+"\n\n"+ + " "+StyleDim.Render("Check the folders in the Folders screen, then try again."), + HelpBar("enter", "back to menu", "esc", "back")) + default: + return m.viewPreview() + } +} + +// frame wraps a body in the standard header and footer. +func (m sortModel) frame(title, body, help string) string { + var b strings.Builder + b.WriteString("\n") + b.WriteString(RenderHeader(title, m.cfg.SourceDir, m.width)) + b.WriteString("\n\n") + b.WriteString(body) + b.WriteString("\n\n") + b.WriteString(help) + b.WriteString("\n") + return b.String() +} + +// viewPreview lists what the policy decided, and why. +func (m sortModel) viewPreview() string { + w := ContentWidth(m.width) + rows := m.visible() + + var b strings.Builder + b.WriteString(fmt.Sprintf(" %s %s %s %s\n", + StyleWarning.Render(fmt.Sprintf("%d to move", m.plan.MoveCount)), + StyleDim.Render("·"), + StyleSuccess.Render(fmt.Sprintf("%d to keep", m.plan.KeepCount)), + StyleDim.Render(fmt.Sprintf("· %d scanned", len(m.plan.Decisions))))) + b.WriteString(" " + StyleSeparator.Render(strings.Repeat("─", w-2)) + "\n\n") + + if len(rows) == 0 { + b.WriteString(" " + StyleSuccess.Render("Nothing to move. Every image matches a keep rule.") + "\n") + b.WriteString(" " + StyleDim.Render("Press v to show the images that are staying.") + "\n") + return m.frame("Preview", b.String(), HelpBar("v", "show all", "esc", "back")) + } + + // Show a window around the cursor so long lists stay navigable. + start, end := window(m.cursor, len(rows), m.listHeight()) + for i := start; i < end; i++ { + d := rows[i] + marker, style := " ", StyleNormal + if i == m.cursor { + marker, style = StyleSelected.Render("▸ "), StyleSelected + } + verdict := StyleWarning.Render("move") + if d.Action == domain.ActionKeep { + verdict = StyleSuccess.Render("keep") + } + b.WriteString(fmt.Sprintf("%s%s %s %s\n", marker, verdict, + style.Render(fmt.Sprintf("%-34s", Truncate(d.Image.Name, 34))), + StyleDim.Render(d.Image.Resolution()))) + } + + if start > 0 || end < len(rows) { + b.WriteString("\n " + StyleDim.Render(fmt.Sprintf("showing %d-%d of %d", start+1, end, len(rows)))) + b.WriteString("\n") + } + + if len(rows) > 0 { + b.WriteString("\n " + StyleSeparator.Render(strings.Repeat("─", w-2)) + "\n") + b.WriteString(" " + StyleDim.Render("why ") + StyleNormal.Render( + Truncate(rows[clampCursor(m.cursor, len(rows))].Reason, w-8)) + "\n") + } + + filter := "show all" + if m.showKeep { + filter = "show moves only" + } + return m.frame("Preview", b.String(), + HelpBarWrap(m.width, "↑/↓", "select", "v", filter, "enter", "sort now", "esc", "back")) +} + +// viewConfirm gates the only destructive step behind an explicit key. +func (m sortModel) viewConfirm() string { + var b strings.Builder + b.WriteString(" " + StyleWarning.Render(fmt.Sprintf("Move %d image(s)?", m.plan.MoveCount)) + "\n\n") + b.WriteString(" " + StyleDim.Render("from ") + StyleNormal.Render(m.cfg.SourceDir) + "\n") + b.WriteString(" " + StyleDim.Render("into ") + StyleNormal.Render(m.cfg.DestDir) + "\n\n") + b.WriteString(" " + StyleDim.Render("Files are never overwritten. A name clash is suffixed instead.") + "\n") + return m.frame("Confirm", b.String(), HelpBar("y", "move them", "n", "go back", "esc", "cancel")) +} + +// viewDone reports the outcome, including any file that could not be moved. +func (m sortModel) viewDone() string { + var b strings.Builder + b.WriteString(" " + StyleSuccess.Render(fmt.Sprintf("Moved %d image(s) into", m.report.Moved)) + "\n") + b.WriteString(" " + StyleNormal.Render(m.cfg.DestDir) + "\n") + + if m.report.Failed > 0 { + b.WriteString("\n " + StyleError.Render(fmt.Sprintf("%d failed:", m.report.Failed)) + "\n") + for _, e := range m.report.Errors { + b.WriteString(" " + StyleDim.Render(Truncate(e, ContentWidth(m.width)-6)) + "\n") + } + b.WriteString("\n " + StyleDim.Render("The full detail is in the log file.") + "\n") + } + return m.frame("Done", b.String(), HelpBar("enter", "back to menu", "esc", "back")) +} + +// listHeight is how many rows the preview list can use. +func (m sortModel) listHeight() int { + if m.height <= 0 { + return 15 + } + if h := m.height - 16; h >= 3 { + return h + } + return 3 +} + +// window returns the slice bounds that keep cursor visible in a list of +// length total showing at most size rows. +func window(cursor, total, size int) (int, int) { + if size >= total { + return 0, total + } + start := cursor - size/2 + if start < 0 { + start = 0 + } + if start+size > total { + start = total - size + } + return start, start + size +} diff --git a/src/internal/tui/sort_test.go b/src/internal/tui/sort_test.go new file mode 100644 index 0000000..cc9115a --- /dev/null +++ b/src/internal/tui/sort_test.go @@ -0,0 +1,196 @@ +package tui + +import ( + "io" + "strings" + "testing" + + "github.com/nerdexecutive/ne-image-sorter/internal/domain" + "github.com/nerdexecutive/ne-image-sorter/internal/sorter" +) + +func TestSortScanThenPreview(t *testing.T) { + m := runInit(sortScreen( + domain.Image{Name: "uhd.jpg", Width: 3840, Height: 2160}, + domain.Image{Name: "wide.jpg", Width: 3440, Height: 1440}, + )) + if m.stage != stagePreview { + t.Fatalf("stage = %v after the scan, want preview", m.stage) + } + if m.plan.MoveCount != 1 || m.plan.KeepCount != 1 { + t.Errorf("plan = %d move, %d keep; want 1 and 1", m.plan.MoveCount, m.plan.KeepCount) + } + + out := m.View() + for _, want := range []string{"1 to move", "1 to keep", "wide.jpg"} { + if !strings.Contains(out, want) { + t.Errorf("preview is missing %q", want) + } + } + // The keep decision is hidden until the filter is toggled. + if strings.Contains(out, "uhd.jpg") { + t.Error("preview must show moves only until v is pressed") + } +} + +func TestSortPreviewShowsTheDecidingReason(t *testing.T) { + m := runInit(sortScreen(domain.Image{Name: "hd.jpg", Width: 1920, Height: 1080})) + if out := m.View(); !strings.Contains(out, "height") { + t.Error("the preview must name the rule that decided the selected image") + } +} + +func TestSortFilterTogglesKeeps(t *testing.T) { + m := runInit(sortScreen( + domain.Image{Name: "uhd.jpg", Width: 3840, Height: 2160}, + domain.Image{Name: "wide.jpg", Width: 3440, Height: 1440}, + )) + m, _ = m.Update(key("v")) + if !strings.Contains(m.View(), "uhd.jpg") { + t.Error("v must reveal the images that are staying") + } +} + +func TestSortRequiresConfirmationBeforeMoving(t *testing.T) { + m := runInit(sortScreen(domain.Image{Name: "wide.jpg", Width: 3440, Height: 1440})) + + m, cmd := m.Update(key("enter")) + if m.stage != stageConfirm { + t.Fatalf("stage = %v after enter, want confirm", m.stage) + } + if cmd != nil { + t.Error("enter must not start moving files; it must ask first") + } + if !strings.Contains(m.View(), "Move 1 image(s)?") { + t.Error("the confirm screen must state how many files move") + } + + // n returns to the preview without moving anything. + back, cmd := m.Update(key("n")) + if back.stage != stagePreview || cmd != nil { + t.Error("n must return to the preview without moving") + } + + // y is the only key that starts the move. + started, cmd := m.Update(key("y")) + if cmd == nil || started.stage != stageScanning { + t.Error("y must start the move") + } +} + +func TestSortConfirmIgnoresOtherKeys(t *testing.T) { + m := runInit(sortScreen(domain.Image{Name: "wide.jpg", Width: 3440, Height: 1440})) + m, _ = m.Update(key("enter")) + for _, k := range []string{"enter", " ", "j", "d"} { + next, cmd := m.Update(key(k)) + if cmd != nil || next.stage != stageConfirm { + t.Errorf("key %q on the confirm screen must not move files", k) + } + } +} + +func TestSortWithNothingToMoveCannotBeConfirmed(t *testing.T) { + m := runInit(sortScreen(domain.Image{Name: "uhd.jpg", Width: 3840, Height: 2160})) + m, _ = m.Update(key("enter")) + if m.stage == stageConfirm { + t.Error("an empty move set must not open the confirm screen") + } + if !strings.Contains(m.View(), "Nothing to move") { + t.Error("an empty move set must render an explicit empty state") + } +} + +func TestSortEmptySourceRenders(t *testing.T) { + m := runInit(sortScreen()) + if out := m.View(); !strings.Contains(out, "0 scanned") { + t.Errorf("an empty source must report zero scanned, got %q", out) + } +} + +func TestSortReportsFailures(t *testing.T) { + m := sortScreen() + m.plan = sorter.Plan{MoveCount: 3} + m, _ = m.Update(reportMsg{report: sorter.Report{Moved: 2, Failed: 1, Errors: []string{"b.jpg: denied"}}}) + + if m.stage != stageDone { + t.Fatalf("stage = %v, want done", m.stage) + } + out := m.View() + for _, want := range []string{"Moved 2", "1 failed", "b.jpg"} { + if !strings.Contains(out, want) { + t.Errorf("the done screen is missing %q", want) + } + } +} + +func TestSortSurfacesAScanError(t *testing.T) { + m := sortScreen() + m, _ = m.Update(planMsg{err: io.ErrUnexpectedEOF}) + if m.stage != stageFailed { + t.Fatalf("stage = %v, want failed", m.stage) + } + if out := m.View(); !strings.Contains(out, "Sort failed") || !strings.Contains(out, "Folders") { + t.Error("a failure must name the problem and suggest the fix") + } +} + +// TestEscapeAlwaysLeavesTheSortScreen pins the keymap consistency rule: esc +// means back on every stage, never something else. +func TestEscapeAlwaysLeavesTheSortScreen(t *testing.T) { + for name, stage := range map[string]sortStage{ + "scanning": stageScanning, "preview": stagePreview, + "confirm": stageConfirm, "done": stageDone, "failed": stageFailed, + } { + m := sortScreen() + m.stage = stage + if _, cmd := m.Update(key("esc")); cmd == nil { + t.Errorf("esc on the %s stage produced no command, want back", name) + } + } +} + +func TestSortNavigationStaysInBounds(t *testing.T) { + m := runInit(sortScreen( + domain.Image{Name: "a.jpg", Width: 3440, Height: 1440}, + domain.Image{Name: "b.jpg", Width: 3440, Height: 1440}, + )) + for i := 0; i < 10; i++ { + m, _ = m.Update(key("down")) + } + if m.cursor != 1 { + t.Errorf("cursor = %d, want it clamped to 1", m.cursor) + } + for i := 0; i < 10; i++ { + m, _ = m.Update(key("up")) + } + if m.cursor != 0 { + t.Errorf("cursor = %d, want it clamped to 0", m.cursor) + } +} + +func TestWindowKeepsTheCursorVisible(t *testing.T) { + tests := []struct { + cursor, total, size int + }{ + {0, 100, 10}, {50, 100, 10}, {99, 100, 10}, {5, 3, 10}, {0, 0, 10}, + } + for _, tc := range tests { + start, end := window(tc.cursor, tc.total, tc.size) + if start < 0 || end > tc.total || start > end { + t.Fatalf("window(%d,%d,%d) = %d,%d is out of range", tc.cursor, tc.total, tc.size, start, end) + } + if tc.total > 0 && tc.cursor < tc.total && (tc.cursor < start || tc.cursor >= end) { + t.Errorf("window(%d,%d,%d) = %d,%d excludes the cursor", tc.cursor, tc.total, tc.size, start, end) + } + } +} + +func TestListHeightHasAFloor(t *testing.T) { + for _, h := range []int{0, -1, 5, 40} { + m := sortScreen() + m.height = h + if got := m.listHeight(); got < 3 { + t.Errorf("listHeight() at terminal height %d = %d, want at least 3", h, got) + } + } +} diff --git a/src/internal/tui/styles.go b/src/internal/tui/styles.go new file mode 100644 index 0000000..9e16737 --- /dev/null +++ b/src/internal/tui/styles.go @@ -0,0 +1,88 @@ +// Package tui is the Bubbletea terminal interface for ne-image-sorter. +package tui + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// Palette, shared with the ne-assistant configurator so the two tools read as +// one family. Contrast ratios against a #1a1a1a terminal background: +// +// ColorCyan #00d7ff ~11:1 +// ColorGreen #00d75f ~8.5:1 +// ColorGray #808080 ~4.6:1 (body text, passes AA 4.5:1) +// ColorDarkGray #6a6a6a ~3.4:1 (separators, passes AA 3:1) +// ColorWhite #ffffff ~17:1 +// ColorRed #ff5f5f ~5.5:1 +// ColorYellow #ffff5f ~16:1 +// ColorMagenta #ff87d7 ~9:1 +var ( + ColorCyan = lipgloss.Color("#00d7ff") + ColorGreen = lipgloss.Color("#00d75f") + ColorGray = lipgloss.Color("#808080") + ColorDarkGray = lipgloss.Color("#6a6a6a") + ColorWhite = lipgloss.Color("#ffffff") + ColorRed = lipgloss.Color("#ff5f5f") + ColorYellow = lipgloss.Color("#ffff5f") + ColorMagenta = lipgloss.Color("#ff87d7") +) + +// Base styles. +var ( + StyleTitle = lipgloss.NewStyle().Foreground(ColorCyan).Bold(true) + StyleSubtitle = lipgloss.NewStyle().Foreground(ColorGray) + StyleSelected = lipgloss.NewStyle().Foreground(ColorGreen).Bold(true) + StyleNormal = lipgloss.NewStyle().Foreground(ColorWhite) + StyleDim = lipgloss.NewStyle().Foreground(ColorGray) + StyleError = lipgloss.NewStyle().Foreground(ColorRed).Bold(true) + StyleSuccess = lipgloss.NewStyle().Foreground(ColorGreen) + StyleWarning = lipgloss.NewStyle().Foreground(ColorYellow) + StyleSeparator = lipgloss.NewStyle().Foreground(ColorDarkGray) + StyleAccent = lipgloss.NewStyle().Foreground(ColorMagenta) + StyleField = lipgloss.NewStyle().Foreground(ColorCyan) +) + +// banner is the title wordmark, drawn once at the top of the main menu. +var banner = []string{ + "┌─┐┌─┐ ┬┌┬┐┌─┐┌─┐┌─┐ ┌─┐┌─┐┬─┐┌┬┐┌─┐┬─┐", + "│││├┤ │││││├─┤│ ┬├┤ └─┐│ │├┬┘ │ ├┤ ├┬┘", + "┘└┘└─┘ ┴┴ ┴┴ ┴└─┘└─┘ └─┘└─┘┴└─ ┴ └─┘┴└─", +} + +// RenderBanner draws the wordmark above a rule, centred to the content width. +func RenderBanner(width int) string { + w := ContentWidth(width) + var b strings.Builder + for _, line := range banner { + b.WriteString(StyleTitle.Render(centre(line, w))) + b.WriteString("\n") + } + b.WriteString(StyleSeparator.Render(strings.Repeat("═", w))) + return b.String() +} + +// RenderHeader draws a screen title, a rule, and an optional subtitle. +func RenderHeader(title, subtitle string, width int) string { + w := ContentWidth(width) + var b strings.Builder + b.WriteString(StyleTitle.Render(title)) + b.WriteString("\n") + b.WriteString(StyleSeparator.Render(strings.Repeat("═", w))) + if subtitle != "" { + b.WriteString("\n") + b.WriteString(StyleSubtitle.Render(subtitle)) + } + return b.String() +} + +// centre pads s with leading spaces so it sits in the middle of width. Rune +// count is used rather than byte length because the wordmark is box-drawing. +func centre(s string, width int) string { + pad := (width - len([]rune(s))) / 2 + if pad <= 0 { + return s + } + return strings.Repeat(" ", pad) + s +} diff --git a/sunrise-mountain-river-landscapej.jpg b/sunrise-mountain-river-landscapej.jpg new file mode 100644 index 0000000..0817c90 Binary files /dev/null and b/sunrise-mountain-river-landscapej.jpg differ diff --git a/sunset-horizon.jpg b/sunset-horizon.jpg new file mode 100644 index 0000000..b5898b3 Binary files /dev/null and b/sunset-horizon.jpg differ diff --git a/sunset_view.jpg b/sunset_view.jpg new file mode 100644 index 0000000..de026bc Binary files /dev/null and b/sunset_view.jpg differ diff --git a/volcanic-cloud-sunrise.jpg b/volcanic-cloud-sunrise.jpg new file mode 100644 index 0000000..22cf884 Binary files /dev/null and b/volcanic-cloud-sunrise.jpg differ