package cmd
import (
"fmt"
"os"
"strconv"
"strings"
tea "charm.land/bubbletea/v2"
"skraak/tools"
"skraak/tui"
"skraak/utils"
)
var reservedClassifyKeys = map[string]string{
",": "previous segment",
".": "next segment",
"0": "confirm label at certainty 100",
" ": "open comment dialog",
}
func printClassifyUsage() {
fmt.Fprintf(os.Stderr, "Usage: skraak calls classify [options]\n\n")
fmt.Fprintf(os.Stderr, "Interactive TUI for reviewing and classifying bird call segments.\n")
fmt.Fprintf(os.Stderr, "Reads .data files (AviaNZ format) and presents segments for labelling\n")
fmt.Fprintf(os.Stderr, "with spectrogram display and audio playback.\n\n")
fmt.Fprintf(os.Stderr, "Options:\n")
fmt.Fprintf(os.Stderr, " --folder <path> Path to folder containing .data files (required, or --file)\n")
fmt.Fprintf(os.Stderr, " --file <path> Path to a single .data file (required, or --folder)\n")
fmt.Fprintf(os.Stderr, " --filter <name> Filter name to scope which segments to review (optional)\n")
fmt.Fprintf(os.Stderr, " --species <name> Scope to species, optionally with calltype (e.g. Kiwi, Kiwi+Duet, Kiwi+_)\n")
fmt.Fprintf(os.Stderr, " Use +_ to match only calls with no calltype\n")
fmt.Fprintf(os.Stderr, " --certainty <int> Scope to certainty value (0-100, optional)\n")
fmt.Fprintf(os.Stderr, " --sample <1-100> Randomly sample N%% of filtered calls (requires --certainty; 100 = no-op)\n")
fmt.Fprintf(os.Stderr, " --goto <filename> Start at this .data file (basename match, optional)\n")
fmt.Fprintf(os.Stderr, " --night Only review solar-night recordings (requires --location)\n")
fmt.Fprintf(os.Stderr, " --day Only review solar-day recordings (requires --location)\n")
fmt.Fprintf(os.Stderr, " --size <int> Spectrogram image size in pixels (224-896, default: config img_dims or 448)\n")
fmt.Fprintf(os.Stderr, " --location <lat,lng[,tz]> GPS coordinates and optional IANA timezone\n")
fmt.Fprintf(os.Stderr, " e.g. --location \"-36.85,174.76\" or --location \"-36.85,174.76,Pacific/Auckland\"\n")
fmt.Fprintf(os.Stderr, " Required with --night or --day. Timezone defaults to UTC.\n")
fmt.Fprintf(os.Stderr, " Not needed for AudioMoth data (UTC from WAV comment).\n")
fmt.Fprintf(os.Stderr, "\nConfig (required): ~/.skraak/config.json\n")
fmt.Fprintf(os.Stderr, " Provides reviewer, keybindings, and display flags (color/sixel/iterm/img_dims).\n")
fmt.Fprintf(os.Stderr, " Example:\n")
fmt.Fprintf(os.Stderr, " {\n")
fmt.Fprintf(os.Stderr, " \"classify\": {\n")
fmt.Fprintf(os.Stderr, " \"reviewer\": \"David\",\n")
fmt.Fprintf(os.Stderr, " \"color\": true,\n")
fmt.Fprintf(os.Stderr, " \"bindings\": {\n")
fmt.Fprintf(os.Stderr, " \"k\": \"Kiwi\",\n")
fmt.Fprintf(os.Stderr, " \"1\": \"Kiwi+Duet\",\n")
fmt.Fprintf(os.Stderr, " \"x\": \"Noise\"\n")
fmt.Fprintf(os.Stderr, " }\n")
fmt.Fprintf(os.Stderr, " }\n")
fmt.Fprintf(os.Stderr, " }\n")
fmt.Fprintf(os.Stderr, "\nExamples:\n")
fmt.Fprintf(os.Stderr, " skraak calls classify --folder /path/to/data\n")
fmt.Fprintf(os.Stderr, " skraak calls classify --file /path/to/file.data --filter opensoundscape-kiwi-1.2\n")
fmt.Fprintf(os.Stderr, " skraak calls classify --folder /path/to/data --species Kiwi+Duet\n")
}
type classifyArgs struct {
folder string
file string
filter string
species string
gotoFile string
certainty int
sample int
size int
night bool
day bool
location string
}
func parseClassifyArgs(args []string) classifyArgs {
a := classifyArgs{certainty: -1, sample: -1, size: 0}
i := 0
for i < len(args) {
arg := args[i]
switch arg {
case "--folder":
a.folder = a.requireValue(args, i, "--folder")
i += 2
case "--file":
a.file = a.requireValue(args, i, "--file")
i += 2
case "--filter":
a.filter = a.requireUniqueValue(args, i, "--filter", a.filter)
i += 2
case "--species":
a.species = a.requireUniqueValue(args, i, "--species", a.species)
i += 2
case "--certainty":
a.certainty = a.requireIntRange(args, i, "--certainty", 0, 100)
i += 2
case "--sample":
a.sample = a.requireIntRange(args, i, "--sample", 1, 100)
i += 2
case "--size":
a.size = a.requireIntRange(args, i, "--size", 224, 896)
i += 2
case "--goto":
a.gotoFile = a.requireValue(args, i, "--goto")
i += 2
case "--location":
a.location = a.requireUniqueValue(args, i, "--location", a.location)
i += 2
case "--night":
a.night = true
i++
case "--day":
a.day = true
i++
case "--help", "-h":
printClassifyUsage()
os.Exit(0)
default:
fmt.Fprintf(os.Stderr, "Error: unknown flag: %s\n\n", arg)
printClassifyUsage()
os.Exit(1)
}
}
return a
}
func (classifyArgs) requireValue(args []string, i int, flag string) string {
if i+1 >= len(args) {
fmt.Fprintf(os.Stderr, "Error: %s requires a value\n", flag)
os.Exit(1)
}
return args[i+1]
}
func (a classifyArgs) requireUniqueValue(args []string, i int, flag, current string) string {
if current != "" {
fmt.Fprintf(os.Stderr, "Error: %s can only be specified once\n", flag)
os.Exit(1)
}
return a.requireValue(args, i, flag)
}
func (a classifyArgs) requireIntRange(args []string, i int, flag string, lo, hi int) int {
val := a.requireValue(args, i, flag)
v, err := strconv.Atoi(val)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %s must be an integer\n", flag)
os.Exit(1)
}
if v < lo || v > hi {
fmt.Fprintf(os.Stderr, "Error: %s must be between %d and %d\n", flag, lo, hi)
os.Exit(1)
}
return v
}
func (a classifyArgs) validate() {
if a.sample > 0 && a.sample < 100 && a.certainty < 0 {
fmt.Fprintf(os.Stderr, "Error: --sample requires --certainty to be set\n")
os.Exit(1)
}
if a.folder == "" && a.file == "" {
fmt.Fprintf(os.Stderr, "Error: missing required flag: --folder or --file\n\n")
printClassifyUsage()
os.Exit(1)
}
if a.night && a.day {
fmt.Fprintf(os.Stderr, "Error: --night and --day are mutually exclusive\n\n")
printClassifyUsage()
os.Exit(1)
}
if (a.night || a.day) && a.location == "" {
fmt.Fprintf(os.Stderr, "Error: --night/--day requires --location\n\n")
printClassifyUsage()
os.Exit(1)
}
}
func validateBindings(cfg *utils.Config, cfgPath string) []tools.KeyBinding {
bindings := make([]tools.KeyBinding, 0, len(cfg.Classify.Bindings))
for key, value := range cfg.Classify.Bindings {
if len(key) != 1 {
fmt.Fprintf(os.Stderr, "Error: binding key %q in %s must be a single character\n", key, cfgPath)
os.Exit(1)
}
if purpose, reserved := reservedClassifyKeys[key]; reserved {
fmt.Fprintf(os.Stderr,
"Error: binding key %q in %s is reserved by the TUI for %s — pick a different key.\n",
key, cfgPath, purpose)
os.Exit(1)
}
bindings = append(bindings, parseBind(key+"="+value))
}
for primaryKey, inner := range cfg.Classify.SecondaryBindings {
if _, ok := cfg.Classify.Bindings[primaryKey]; !ok {
fmt.Fprintf(os.Stderr,
"Error: secondary_bindings key %q in %s has no matching primary binding\n",
primaryKey, cfgPath)
os.Exit(1)
}
for k, v := range inner {
if len(k) != 1 {
fmt.Fprintf(os.Stderr,
"Error: secondary_bindings[%q] key %q in %s must be a single character\n",
primaryKey, k, cfgPath)
os.Exit(1)
}
if purpose, reserved := reservedClassifyKeys[k]; reserved {
fmt.Fprintf(os.Stderr,
"Error: secondary_bindings[%q] key %q in %s is reserved by the TUI for %s — pick a different key.\n",
primaryKey, k, cfgPath, purpose)
os.Exit(1)
}
if v == "" {
fmt.Fprintf(os.Stderr,
"Error: secondary_bindings[%q][%q] in %s has empty calltype\n",
primaryKey, k, cfgPath)
os.Exit(1)
}
}
}
return bindings
}
func classifyImageSize(cliSize, configSize int) int {
if cliSize > 0 {
return cliSize
}
return configSize
}
func RunCallsClassify(args []string) {
a := parseClassifyArgs(args)
a.validate()
cfg, cfgPath, err := utils.LoadConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
fmt.Fprintf(os.Stderr, "Create %s with a \"classify\" section; run `skraak calls classify --help` for an example.\n", cfgPath)
os.Exit(1)
}
if cfg.Classify.Reviewer == "" {
fmt.Fprintf(os.Stderr, "Error: %s is missing \"classify.reviewer\"\n", cfgPath)
os.Exit(1)
}
if len(cfg.Classify.Bindings) == 0 {
fmt.Fprintf(os.Stderr, "Error: %s is missing \"classify.bindings\" (need at least one key)\n", cfgPath)
os.Exit(1)
}
bindings := validateBindings(&cfg, cfgPath)
speciesName, callType := utils.ParseSpeciesCallType(a.species)
var lat, lng float64
var timezone string
if a.location != "" {
var err error
lat, lng, timezone, err = utils.ParseLocation(a.location)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
config := tools.ClassifyConfig{
Folder: a.folder,
File: a.file,
Filter: a.filter,
Species: speciesName,
CallType: callType,
Certainty: a.certainty,
Sample: a.sample,
Goto: a.gotoFile,
Reviewer: cfg.Classify.Reviewer,
Color: cfg.Classify.Color,
ImageSize: classifyImageSize(a.size, cfg.Classify.ImgDims),
Sixel: cfg.Classify.Sixel,
ITerm: cfg.Classify.ITerm,
Bindings: bindings,
SecondaryBindings: cfg.Classify.SecondaryBindings,
Night: a.night,
Day: a.day,
Lat: lat,
Lng: lng,
Timezone: timezone,
}
state, err := tools.LoadDataFiles(config)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if state.TimeFilteredCount > 0 {
label := "daytime"
if config.Day {
label = "nighttime"
}
fmt.Fprintf(os.Stderr, "Skipped %d %s files\n", state.TimeFilteredCount, label)
}
fmt.Fprintf(os.Stderr, "Loaded %d files with %d matching segments\n",
len(state.DataFiles), state.TotalSegments())
if state.TotalSegments() == 0 {
fmt.Fprintf(os.Stderr, "No segments to review.\n")
os.Exit(0)
}
p := tea.NewProgram(tui.New(state))
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
func parseBind(s string) tools.KeyBinding {
parts := strings.SplitN(s, "=", 2)
if len(parts) != 2 {
fmt.Fprintf(os.Stderr, "Error: invalid bind format: %s (expected key=value)\n", s)
os.Exit(1)
}
key := parts[0]
value := parts[1]
if strings.Contains(value, "+") {
valueParts := strings.SplitN(value, "+", 2)
return tools.KeyBinding{
Key: key,
Species: valueParts[0],
CallType: valueParts[1],
}
}
return tools.KeyBinding{
Key: key,
Species: value,
}
}