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"
}