Fork channel

Create a new channel as a copy of main.

Rename channel

Rename main to:

Delete channel

Delete main? This cannot be undone.

calls_remove.go
package calls

import (
	"fmt"
	"os"

	"skraak/datafile"
)

// CallsRemoveInput defines the input for the remove tool
type CallsRemoveInput struct {
	File      string `json:"file"`
	Segment   string `json:"segment"`
	Frequency string `json:"frequency,omitempty"`
	Species   string `json:"species"`
	Filter    string `json:"filter"`
	Reviewer  string `json:"reviewer"`
}

// CallsRemoveOutput defines the output for the remove tool
type CallsRemoveOutput struct {
	File         string  `json:"file"`
	SegmentStart float64 `json:"segment_start"`
	SegmentEnd   float64 `json:"segment_end"`
	LowFreq      float64 `json:"low_freq,omitempty"`
	HighFreq     float64 `json:"high_freq,omitempty"`
	Species      string  `json:"species"`
	CallType     string  `json:"calltype,omitempty"`
	Filter       string  `json:"filter"`
	Removed      string  `json:"removed"` // "label", "segment", or "file"
	Error        string  `json:"error,omitempty"`
}

// validateRemoveInput checks required fields.
func validateRemoveInput(input CallsRemoveInput) error {
	if input.File == "" {
		return fmt.Errorf("--file is required")
	}
	if input.Segment == "" {
		return fmt.Errorf("--segment is required")
	}
	if input.Species == "" {
		return fmt.Errorf("--species is required")
	}
	if input.Reviewer == "" {
		return fmt.Errorf("--reviewer is required")
	}
	return nil
}

// removeOutputError sets the error field and returns.
func removeOutputError(output *CallsRemoveOutput, msg string) (CallsRemoveOutput, error) {
	output.Error = msg
	return *output, fmt.Errorf("%s", msg)
}

// findSegmentsByTimeRange finds all segments matching the given start/end times.
func findSegmentsByTimeRange(segments []*datafile.Segment, startTime, endTime float64) []*datafile.Segment {
	var matches []*datafile.Segment
	for _, seg := range segments {
		if seg.StartTime == startTime && seg.EndTime == endTime {
			matches = append(matches, seg)
		}
	}
	return matches
}

// findMatchingLabels finds labels in a segment that match species (and optionally calltype).
// If callType is empty, matches any calltype for that species.
// Returns matched labels and whether the match was ambiguous.
func findMatchingLabels(segment *datafile.Segment, species, callType, filter string) ([]*datafile.Label, string) {
	matches := filterLabelsBySpecies(segment.Labels, species, callType, filter)

	if len(matches) == 0 {
		return nil, ""
	}

	if callType == "" && len(matches) > 1 {
		return nil, formatAmbiguousCalltypeError(species, filter, matches)
	}

	return matches, ""
}

// filterLabelsBySpecies returns labels matching the given species, calltype, and filter.
func filterLabelsBySpecies(labels []*datafile.Label, species, callType, filter string) []*datafile.Label {
	var matches []*datafile.Label
	for _, label := range labels {
		if label.Species != species || label.Filter != filter {
			continue
		}
		if callType != "" && label.CallType != callType {
			continue
		}
		matches = append(matches, label)
	}
	return matches
}

// formatAmbiguousCalltypeError creates an error message for ambiguous calltype matches.
func formatAmbiguousCalltypeError(species, filter string, matches []*datafile.Label) string {
	var callTypes []string
	for _, l := range matches {
		ct := l.CallType
		if ct == "" {
			ct = "(none)"
		}
		callTypes = append(callTypes, ct)
	}
	return fmt.Sprintf("multiple labels match species '%s' with filter '%s', specify --calltype to disambiguate (calltypes: %v)", species, filter, callTypes)
}

// removeLabelFromSegment removes specific labels from a segment.
func removeLabelFromSegment(segment *datafile.Segment, toRemove []*datafile.Label) {
	removeSet := make(map[*datafile.Label]bool)
	for _, l := range toRemove {
		removeSet[l] = true
	}

	var remaining []*datafile.Label
	for _, label := range segment.Labels {
		if !removeSet[label] {
			remaining = append(remaining, label)
		}
	}

	segment.Labels = remaining
}

// removeSegmentFromDataFile removes a segment from the data file.
func removeSegmentFromDataFile(df *datafile.DataFile, seg *datafile.Segment) {
	var remaining []*datafile.Segment
	for _, s := range df.Segments {
		if s != seg {
			remaining = append(remaining, s)
		}
	}
	df.Segments = remaining
}

// resolveTargetSegment finds the target segment for removal, handling frequency ambiguity.
func resolveTargetSegment(dataFile *datafile.DataFile, input CallsRemoveInput, output *CallsRemoveOutput) (*datafile.Segment, error) {
	matchingSegments := findSegmentsByTimeRange(dataFile.Segments, output.SegmentStart, output.SegmentEnd)

	if len(matchingSegments) == 0 {
		return nil, fmt.Errorf("no segment found matching time range %.1f-%.1f", output.SegmentStart, output.SegmentEnd)
	}

	// If multiple segments match and frequency not specified, error
	if len(matchingSegments) > 1 && input.Frequency == "" {
		return nil, fmt.Errorf("multiple segments match time range %.1f-%.1f, specify --frequency to disambiguate", output.SegmentStart, output.SegmentEnd)
	}

	// Parse frequency if provided
	if input.Frequency != "" {
		freqLow, freqHigh, err := parseFrequencyRange(input.Frequency)
		if err != nil {
			return nil, err
		}
		target := findExactSegment(dataFile.Segments, output.SegmentStart, output.SegmentEnd, freqLow, freqHigh)
		if target == nil {
			return nil, fmt.Errorf("no segment found matching time range %.1f-%.1f and frequency %.0f-%.0f", output.SegmentStart, output.SegmentEnd, freqLow, freqHigh)
		}
		output.LowFreq = freqLow
		output.HighFreq = freqHigh
		return target, nil
	}

	// Exactly one segment matches the time range
	target := matchingSegments[0]
	output.LowFreq = target.FreqLow
	output.HighFreq = target.FreqHigh
	return target, nil
}

// resolveTargetLabels finds matching labels in the segment, handling calltype ambiguity.
func resolveTargetLabels(segment *datafile.Segment, species, callType, filter string) ([]*datafile.Label, error) {
	matchingLabels, ambiguityErr := findMatchingLabels(segment, species, callType, filter)
	if ambiguityErr != "" {
		return nil, fmt.Errorf("%s", ambiguityErr)
	}
	if len(matchingLabels) == 0 {
		return nil, fmt.Errorf("no label found matching species '%s' with filter '%s'", species, filter)
	}
	return matchingLabels, nil
}

// CallsRemove removes a label/segment from a .data file
func CallsRemove(input CallsRemoveInput) (CallsRemoveOutput, error) {
	var output CallsRemoveOutput

	if err := validateRemoveInput(input); err != nil {
		return removeOutputError(&output, err.Error())
	}

	startTime, endTime, err := parseSegmentRangeFloat(input.Segment)
	if err != nil {
		return removeOutputError(&output, err.Error())
	}

	species, callType := datafile.ParseSpeciesCallType(input.Species)

	output.File = input.File
	output.SegmentStart = startTime
	output.SegmentEnd = endTime
	output.Species = species
	output.CallType = callType
	output.Filter = input.Filter

	// File must exist
	if _, err := os.Stat(input.File); os.IsNotExist(err) {
		return removeOutputError(&output, fmt.Sprintf("file not found: %s", input.File))
	}

	dataFile, err := datafile.ParseDataFile(input.File)
	if err != nil {
		return removeOutputError(&output, fmt.Sprintf("failed to parse file: %v", err))
	}

	// Find target segment
	targetSegment, err := resolveTargetSegment(dataFile, input, &output)
	if err != nil {
		return removeOutputError(&output, err.Error())
	}

	// Find matching labels
	matchingLabels, err := resolveTargetLabels(targetSegment, species, callType, input.Filter)
	if err != nil {
		return removeOutputError(&output, err.Error())
	}

	// Update reviewer
	dataFile.Meta.Reviewer = input.Reviewer

	// Remove the label(s)
	removeLabelFromSegment(targetSegment, matchingLabels)

	// Handle the result of label removal
	output.Removed = handleRemovalResult(dataFile, targetSegment, input.File)

	// Handle file removal case
	if output.Removed == "file" {
		return output, nil
	}

	if writeErr := dataFile.Write(input.File); writeErr != nil {
		return removeOutputError(&output, fmt.Sprintf("failed to save file: %v", writeErr))
	}

	return output, nil
}

// handleRemovalResult handles segment/file removal after labels are removed.
// Returns "label", "segment", or "file" depending on what was removed.
func handleRemovalResult(dataFile *datafile.DataFile, targetSegment *datafile.Segment, filePath string) string {
	if len(targetSegment.Labels) > 0 {
		return "label"
	}

	removeSegmentFromDataFile(dataFile, targetSegment)

	if len(dataFile.Segments) > 0 {
		return "segment"
	}

	// No segments left, remove the .data file
	os.Remove(filePath)
	return "file"
}