edit in tools/import/test_helpers.go at line 11
edit in tools/import/test_helpers.go at line 179
[3.6035]→[3.6035:6290](∅→∅) −
− // createTestWAVWithMetadata creates a WAV file and inserts it into the database.
− // Returns the file ID and hash.
− func createTestWAVWithMetadata(t *testing.T, database *sql.DB, clusterID, locationID, filename string) (fileID, hash string) {
− t.Helper()
edit in tools/import/test_helpers.go at line 180
[3.6291]→[3.6291:6949](∅→∅) − // Create temp file
− tmpDir := t.TempDir()
− wavPath := filepath.Join(tmpDir, filename)
− hash = createTestWAV(t, wavPath)
−
− // Generate file ID
− fileID, err := utils.GenerateLongID()
− if err != nil {
− t.Fatalf("failed to generate file ID: %v", err)
− }
−
− // Insert file record
− _, err = database.ExecContext(context.Background(), `
− INSERT INTO file (id, file_name, xxh64_hash, location_id, cluster_id, timestamp_local, duration, sample_rate, active)
− VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, 0.0005, 16000, true)
− `, fileID, filename, hash, locationID, clusterID)
− if err != nil {
− t.Fatalf("failed to insert file: %v", err)
− }
−
− return fileID, hash
− }
−
replacement in tools/import/test_helpers.go at line 241
[3.8431]→[3.8431:8451](∅→∅) + defer func() { _ = file.Close() }()
edit in tools/import/test_helpers.go at line 268
[3.9237]→[3.9237:9655](∅→∅) − }
−
− // assertFileCount queries the database and asserts the expected number of files.
− func assertFileCount(t *testing.T, database *sql.DB, expected int) {
− t.Helper()
− var count int
− if err := database.QueryRow("SELECT COUNT(*) FROM file WHERE active = true").Scan(&count); err != nil {
− t.Fatalf("failed to count files: %v", err)
− }
− if count != expected {
− t.Errorf("expected %d files, got %d", expected, count)
− }
edit in tools/import/test_helpers.go at line 270
[3.9658]→[3.9658:10819](∅→∅) − // assertSegmentCount queries the database and asserts the expected number of segments.
− func assertSegmentCount(t *testing.T, database *sql.DB, expected int) {
− t.Helper()
− var count int
− if err := database.QueryRow("SELECT COUNT(*) FROM segment WHERE active = true").Scan(&count); err != nil {
− t.Fatalf("failed to count segments: %v", err)
− }
− if count != expected {
− t.Errorf("expected %d segments, got %d", expected, count)
− }
− }
−
− // assertLabelCount queries the database and asserts the expected number of labels.
− func assertLabelCount(t *testing.T, database *sql.DB, expected int) {
− t.Helper()
− var count int
− if err := database.QueryRow("SELECT COUNT(*) FROM label WHERE active = true").Scan(&count); err != nil {
− t.Fatalf("failed to count labels: %v", err)
− }
− if count != expected {
− t.Errorf("expected %d labels, got %d", expected, count)
− }
− }
−
− // getTestLocationData returns location data for testing.
− func getTestLocationData(t *testing.T, database *sql.DB, locationID string) *LocationData {
− t.Helper()
− data, err := GetLocationData(database, locationID)
− if err != nil {
− t.Fatalf("failed to get location data: %v", err)
− }
− return data
− }
−
edit in tools/import/test_helpers.go at line 279
[3.11108]→[3.11108:11251](∅→∅) −
− // waitForAsync waits for a short duration to allow async operations to complete.
− func waitForAsync() {
− time.Sleep(100 * time.Millisecond)
− }
edit in tools/import/import_unstructured.go at line 209
+ if recursive {
+ return scanWavFilesRecursive(folderPath)
+ }
+ return scanWavFilesFlat(folderPath)
+ }
+
+ // scanWavFilesRecursive walks the directory tree recursively.
+ func scanWavFilesRecursive(folderPath string) ([]string, []FileImportError) {
replacement in tools/import/import_unstructured.go at line 220
[4.6081]→[4.6081:6146](∅→∅) − walkFunc := func(path string, d fs.DirEntry, err error) error {
+ err := filepath.WalkDir(folderPath, func(path string, d fs.DirEntry, err error) error {
edit in tools/import/import_unstructured.go at line 229
[4.6316]→[4.6316:6356](∅→∅) −
− // Skip directories if not recursive
edit in tools/import/import_unstructured.go at line 230
[4.6373]→[4.6373:6441](∅→∅) − if !recursive && path != folderPath {
− return fs.SkipDir
− }
replacement in tools/import/import_unstructured.go at line 232
[4.6459]→[4.6459:6569](∅→∅) −
− // Check for .wav extension (case-insensitive)
− if strings.HasSuffix(strings.ToLower(d.Name()), ".wav") {
+ if isWavFile(d.Name()) {
edit in tools/import/import_unstructured.go at line 235
[4.6604]→[4.6604:6605](∅→∅) edit in tools/import/import_unstructured.go at line 236
+ })
+ if err != nil {
+ errors = append(errors, FileImportError{
+ FileName: folderPath,
+ Error: err.Error(),
+ Stage: StageScan,
+ })
edit in tools/import/import_unstructured.go at line 244
+ return files, errors
+ }
replacement in tools/import/import_unstructured.go at line 247
[4.6622]→[4.6622:6703](∅→∅),
[4.6703]→[4.47089:47133](∅→∅),
[4.47133]→[4.6753:6806](∅→∅),
[4.6753]→[4.6753:6806](∅→∅),
[4.6806]→[4.47134:47159](∅→∅),
[4.47159]→[4.6837:6956](∅→∅),
[4.6837]→[4.6837:6956](∅→∅),
[4.6956]→[4.47160:47204](∅→∅),
[4.47204]→[4.7006:7059](∅→∅),
[4.7006]→[4.7006:7059](∅→∅),
[4.7059]→[4.47205:47230](∅→∅),
[4.47230]→[4.7090:7122](∅→∅),
[4.7090]→[4.7090:7122](∅→∅) − if recursive {
− if err := filepath.WalkDir(folderPath, walkFunc); err != nil {
− errors = append(errors, FileImportError{
− FileName: folderPath,
− Error: err.Error(),
− Stage: StageScan,
− })
− }
− } else {
− // Non-recursive: only scan top-level
− entries, err := os.ReadDir(folderPath)
− if err != nil {
− errors = append(errors, FileImportError{
− FileName: folderPath,
− Error: err.Error(),
− Stage: StageScan,
− })
− return nil, errors
− }
+ // scanWavFilesFlat scans only the top-level directory.
+ func scanWavFilesFlat(folderPath string) ([]string, []FileImportError) {
+ var files []string
+ var errors []FileImportError
+
+ entries, err := os.ReadDir(folderPath)
+ if err != nil {
+ errors = append(errors, FileImportError{
+ FileName: folderPath,
+ Error: err.Error(),
+ Stage: StageScan,
+ })
+ return nil, errors
+ }
replacement in tools/import/import_unstructured.go at line 262
[4.7123]→[4.7123:7312](∅→∅) − for _, entry := range entries {
− if !entry.IsDir() && strings.HasSuffix(strings.ToLower(entry.Name()), ".wav") {
− files = append(files, filepath.Join(folderPath, entry.Name()))
− }
+ for _, entry := range entries {
+ if !entry.IsDir() && isWavFile(entry.Name()) {
+ files = append(files, filepath.Join(folderPath, entry.Name()))
edit in tools/import/import_unstructured.go at line 267
+ return files, errors
+ }
replacement in tools/import/import_unstructured.go at line 270
[4.7320]→[4.7320:7342](∅→∅) + // isWavFile returns true if the filename has a .wav extension (case-insensitive).
+ func isWavFile(name string) bool {
+ return strings.HasSuffix(strings.ToLower(name), ".wav")
edit in tools/import/import_segments.go at line 582
+ }
+
+ // resolvedLabelIDs holds the resolved database IDs for a label.
+ type resolvedLabelIDs struct {
+ speciesID string
+ filterID string
+ labelID string
+ dbSpecies string
replacement in tools/import/import_segments.go at line 592
[4.27233]→[4.27233:27379](∅→∅) − // importSingleLabel inserts a single label and its metadata/subtype into the DB.
− func importSingleLabel(
− ctx context.Context,
− tx *db.LoggedTx,
+ // resolveLabelIDs looks up species and filter IDs, generates a label ID.
+ // Returns an error if any lookup fails.
+ func resolveLabelIDs(
edit in tools/import/import_segments.go at line 596
[2.60206]→[4.27400:27442](∅→∅),
[4.27400]→[4.27400:27442](∅→∅) − segmentID string,
− segIdx, labelIdx int,
replacement in tools/import/import_segments.go at line 600
[4.27556]→[4.27556:27623](∅→∅) − calltypeIDMap map[string]map[string]string,
− ) importLabelResult {
+ ) (resolvedLabelIDs, error) {
replacement in tools/import/import_segments.go at line 603
[4.27687]→[4.27687:27739](∅→∅),
[4.27739]→[4.48473:48530](∅→∅),
[4.48530]→[4.27802:27899](∅→∅),
[4.27802]→[4.27802:27899](∅→∅) − return importLabelResult{err: ImportSegmentError{
− File: filepath.Base(sf.DataPath), Stage: StageImport,
− Message: fmt.Sprintf("species not found in mapping: %s", label.Species),
− }, hasError: true}
+ return resolvedLabelIDs{}, fmt.Errorf("species not found in mapping: %s", label.Species)
replacement in tools/import/import_segments.go at line 608
[4.27955]→[4.27955:28007](∅→∅),
[4.28007]→[4.48531:48588](∅→∅),
[4.48588]→[4.28070:28155](∅→∅),
[4.28070]→[4.28070:28155](∅→∅) − return importLabelResult{err: ImportSegmentError{
− File: filepath.Base(sf.DataPath), Stage: StageImport,
− Message: fmt.Sprintf("species ID not found: %s", dbSpecies),
− }, hasError: true}
+ return resolvedLabelIDs{}, fmt.Errorf("species ID not found: %s", dbSpecies)
replacement in tools/import/import_segments.go at line 613
[4.28212]→[4.28212:28264](∅→∅),
[4.28264]→[4.48589:48646](∅→∅),
[4.48646]→[4.28327:28414](∅→∅),
[4.28327]→[4.28327:28414](∅→∅) − return importLabelResult{err: ImportSegmentError{
− File: filepath.Base(sf.DataPath), Stage: StageImport,
− Message: fmt.Sprintf("filter ID not found: %s", label.Filter),
− }, hasError: true}
+ return resolvedLabelIDs{}, fmt.Errorf("filter ID not found: %s", label.Filter)
edit in tools/import/import_segments.go at line 617
+ if err != nil {
+ return resolvedLabelIDs{}, fmt.Errorf("failed to generate label ID: %w", err)
+ }
+
+ return resolvedLabelIDs{
+ speciesID: speciesID,
+ filterID: filterID,
+ labelID: labelID,
+ dbSpecies: dbSpecies,
+ }, nil
+ }
+
+ // importSingleLabel inserts a single label and its metadata/subtype into the DB.
+ func importSingleLabel(
+ ctx context.Context,
+ tx *db.LoggedTx,
+ label *datafile.Label,
+ segmentID string,
+ segIdx, labelIdx int,
+ sf scannedDataFile,
+ mapping MappingFile,
+ filterIDMap map[string]string,
+ speciesIDMap map[string]string,
+ calltypeIDMap map[string]map[string]string,
+ ) importLabelResult {
+ // Resolve all IDs first
+ ids, err := resolveLabelIDs(label, sf, mapping, filterIDMap, speciesIDMap)
replacement in tools/import/import_segments.go at line 647
[4.48704]→[4.28590:28655](∅→∅),
[4.28590]→[4.28590:28655](∅→∅) − Message: fmt.Sprintf("failed to generate label ID: %v", err),
replacement in tools/import/import_segments.go at line 651
[4.28680]→[4.28680:28942](∅→∅) − _, err = tx.ExecContext(ctx, `
− INSERT INTO label (id, segment_id, species_id, filter_id, certainty, created_at, last_modified, active)
− VALUES (?, ?, ?, ?, ?, now(), now(), true)
− `, labelID, segmentID, speciesID, filterID, label.Certainty)
− if err != nil {
+ // Insert the label
+ if err := insertLabel(ctx, tx, ids, segmentID, label); err != nil {
replacement in tools/import/import_segments.go at line 655
[4.48762]→[4.29057:29117](∅→∅),
[4.29057]→[4.29057:29117](∅→∅) − Message: fmt.Sprintf("failed to insert label: %v", err),
replacement in tools/import/import_segments.go at line 661
[4.29212]→[4.29212:29542](∅→∅) − escapedComment := strings.ReplaceAll(label.Comment, `"`, `\"`)
− metadataJSON := fmt.Sprintf(`{"comment": "%s"}`, escapedComment)
− if _, err := tx.ExecContext(ctx, `
− INSERT INTO label_metadata (label_id, json, created_at, last_modified, active)
− VALUES (?, ?, now(), now(), true)
− `, labelID, metadataJSON); err != nil {
+ if err := insertLabelMetadata(ctx, tx, ids.labelID, label.Comment); err != nil {
replacement in tools/import/import_segments.go at line 664
[4.48821]→[4.29659:29729](∅→∅),
[4.29659]→[4.29659:29729](∅→∅) − Message: fmt.Sprintf("failed to insert label_metadata: %v", err),
replacement in tools/import/import_segments.go at line 670
[4.29788]→[4.29788:29834](∅→∅) − LabelID: labelID,
− Species: dbSpecies,
+ LabelID: ids.labelID,
+ Species: ids.dbSpecies,
replacement in tools/import/import_segments.go at line 681
[4.30033]→[4.30033:30203](∅→∅) − if err := importCalltype(ctx, tx, labelID, label, dbSpecies, filterID, mapping, calltypeIDMap, sf); err != nil {
− return importLabelResult{err: *err, hasError: true}
+ if ctErr := importCalltype(ctx, tx, ids.labelID, label, ids.dbSpecies, ids.filterID, mapping, calltypeIDMap, sf); ctErr != nil {
+ return importLabelResult{err: *ctErr, hasError: true}
replacement in tools/import/import_segments.go at line 685
[4.30285]→[4.30285:30377](∅→∅) − return importLabelResult{labelImport: labelImport, labelID: labelID, subtypesImported: 1}
+ return importLabelResult{labelImport: labelImport, labelID: ids.labelID, subtypesImported: 1}
edit in tools/import/import_segments.go at line 687
+
+ return importLabelResult{labelImport: labelImport, labelID: ids.labelID}
+ }
replacement in tools/import/import_segments.go at line 691
[4.30381]→[4.30381:30451](∅→∅) − return importLabelResult{labelImport: labelImport, labelID: labelID}
+ // insertLabel inserts a label row into the database.
+ func insertLabel(ctx context.Context, tx *db.LoggedTx, ids resolvedLabelIDs, segmentID string, label *datafile.Label) error {
+ _, err := tx.ExecContext(ctx, `
+ INSERT INTO label (id, segment_id, species_id, filter_id, certainty, created_at, last_modified, active)
+ VALUES (?, ?, ?, ?, ?, now(), now(), true)
+ `, ids.labelID, segmentID, ids.speciesID, ids.filterID, label.Certainty)
+ if err != nil {
+ return fmt.Errorf("failed to insert label: %w", err)
+ }
+ return nil
edit in tools/import/import_segments.go at line 703
+ // insertLabelMetadata inserts a label_metadata row for a comment.
+ func insertLabelMetadata(ctx context.Context, tx *db.LoggedTx, labelID, comment string) error {
+ escapedComment := strings.ReplaceAll(comment, `"`, `\\"`)
+ metadataJSON := fmt.Sprintf(`{"comment": "%s"}`, escapedComment)
+ _, err := tx.ExecContext(ctx, `
+ INSERT INTO label_metadata (label_id, json, created_at, last_modified, active)
+ VALUES (?, ?, now(), now(), true)
+ `, labelID, metadataJSON)
+ if err != nil {
+ return fmt.Errorf("failed to insert label_metadata: %w", err)
+ }
+ return nil
+ }
+
edit in tools/import/import_files_test.go at line 212
[3.33668]→[3.33668:33782](∅→∅) − // boolPtr returns a pointer to the bool value.
− //
− //go:fix inline
− func boolPtr(v bool) *bool {
− return new(v)
− }
−
replacement in tools/calls/calls_show_images.go at line 32
[4.83841]→[4.83841:84001](∅→∅) − // Validate file exists
− if _, err := os.Stat(input.DataFilePath); os.IsNotExist(err) {
− output.Error = fmt.Sprintf("File not found: %s", input.DataFilePath)
+ // Validate input and get data file
+ dataFile, wavPath, err := validateAndParseShowImagesInput(input)
+ if err != nil {
+ output.Error = err.Error()
+ return output, err
+ }
+ output.WavFile = wavPath
+
+ if len(dataFile.Segments) == 0 {
+ output.Error = "No segments found in .data file"
replacement in tools/calls/calls_show_images.go at line 45
[4.84053]→[4.84053:84185](∅→∅) − // Derive WAV file path (strip .data suffix)
− wavPath := strings.TrimSuffix(input.DataFilePath, ".data")
− output.WavFile = wavPath
+ // Resolve image size and protocol
+ imgSize := resolveImageSize(input.ImageSize)
+ protocol := resolveGraphicsProtocol(input)
replacement in tools/calls/calls_show_images.go at line 49
[4.84186]→[4.84186:84212](∅→∅) − // Check WAV file exists
+ // Generate and display spectrograms
+ segmentsShown, err := displaySegmentSpectrograms(input.DataFilePath, dataFile.Segments, input.Color, imgSize, protocol)
+ if err != nil {
+ output.Error = err.Error()
+ return output, err
+ }
+
+ output.SegmentsShown = segmentsShown
+ return output, nil
+ }
+
+ // validateAndParseShowImagesInput validates input files and parses the .data file.
+ func validateAndParseShowImagesInput(input CallsShowImagesInput) (*datafile.DataFile, string, error) {
+ if _, err := os.Stat(input.DataFilePath); os.IsNotExist(err) {
+ return nil, "", fmt.Errorf("file not found: %s", input.DataFilePath)
+ }
+
+ wavPath := strings.TrimSuffix(input.DataFilePath, ".data")
replacement in tools/calls/calls_show_images.go at line 68
[4.84265]→[4.84265:84377](∅→∅) − output.Error = fmt.Sprintf("WAV file not found: %s", wavPath)
− return output, fmt.Errorf("%s", output.Error)
+ return nil, "", fmt.Errorf("WAV file not found: %s", wavPath)
edit in tools/calls/calls_show_images.go at line 71
[4.84381]→[4.84381:84441](∅→∅) − // Parse .data file (includes labels for future filtering)
replacement in tools/calls/calls_show_images.go at line 73
[4.84516]→[4.84516:84593](∅→∅) − output.Error = err.Error()
− return output, fmt.Errorf("%s", output.Error)
replacement in tools/calls/calls_show_images.go at line 76
[4.84597]→[4.84597:84733](∅→∅) − if len(dataFile.Segments) == 0 {
− output.Error = "No segments found in .data file"
− return output, fmt.Errorf("%s", output.Error)
− }
+ return dataFile, wavPath, nil
+ }
replacement in tools/calls/calls_show_images.go at line 79
[4.84734]→[4.84734:84804](∅→∅),
[4.84804]→[4.45578:45625](∅→∅) − // Resolve image size
− imgSize := input.ImageSize
− if imgSize == 0 {
− imgSize = spectrogram.SpectrogramDisplaySize
+ // resolveImageSize returns the image size, using default if zero.
+ func resolveImageSize(size int) int {
+ if size == 0 {
+ return spectrogram.SpectrogramDisplaySize
edit in tools/calls/calls_show_images.go at line 84
replacement in tools/calls/calls_show_images.go at line 87
[4.84849]→[4.84849:84878](∅→∅),
[4.84878]→[4.45626:45665](∅→∅) − // Select graphics protocol
− protocol := spectrogram.ProtocolKitty
+ // resolveGraphicsProtocol selects the terminal graphics protocol.
+ func resolveGraphicsProtocol(input CallsShowImagesInput) spectrogram.ImageProtocol {
replacement in tools/calls/calls_show_images.go at line 90
[4.84929]→[4.45666:45705](∅→∅),
[4.45705]→[4.84962:84987](∅→∅),
[4.84962]→[4.84962:84987](∅→∅),
[4.84987]→[4.45706:45745](∅→∅) − protocol = spectrogram.ProtocolITerm
− } else if input.Sixel {
− protocol = spectrogram.ProtocolSixel
+ return spectrogram.ProtocolITerm
edit in tools/calls/calls_show_images.go at line 92
+ if input.Sixel {
+ return spectrogram.ProtocolSixel
+ }
+ return spectrogram.ProtocolKitty
+ }
replacement in tools/calls/calls_show_images.go at line 98
[4.85024]→[4.85024:85150](∅→∅),
[4.85150]→[4.45746:45869](∅→∅) − // Generate spectrogram for each segment and output
− for i, seg := range dataFile.Segments {
− // Generate spectrogram image
− img, err := spectrogram.GenerateSegmentSpectrogram(input.DataFilePath, seg.StartTime, seg.EndTime, input.Color, imgSize)
+ // displaySegmentSpectrograms generates and displays spectrograms for each segment.
+ func displaySegmentSpectrograms(dataFilePath string, segments []*datafile.Segment, color bool, imgSize int, protocol spectrogram.ImageProtocol) (int, error) {
+ shown := 0
+ for i, seg := range segments {
+ img, err := spectrogram.GenerateSegmentSpectrogram(dataFilePath, seg.StartTime, seg.EndTime, color, imgSize)
edit in tools/calls/calls_show_images.go at line 107
[4.85316]→[4.85316:85340](∅→∅) edit in tools/calls/calls_show_images.go at line 111
[4.85528]→[4.85528:85580](∅→∅) − // Write to stdout via terminal graphics protocol
replacement in tools/calls/calls_show_images.go at line 112
[4.45945]→[4.85649:85762](∅→∅),
[4.85649]→[4.85649:85762](∅→∅) − output.Error = fmt.Sprintf("Failed to write image: %v", err)
− return output, fmt.Errorf("%s", output.Error)
+ return shown, fmt.Errorf("failed to write image: %w", err)
replacement in tools/calls/calls_show_images.go at line 114
[4.85766]→[4.85766:85805](∅→∅) − fmt.Println() // Newline after image
replacement in tools/calls/calls_show_images.go at line 117
[4.85808]→[4.85808:85876](∅→∅) −
− output.SegmentsShown = len(dataFile.Segments)
− return output, nil
edit in tools/calls/calls_remove.go at line 72
+ 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 {
replacement in tools/calls/calls_remove.go at line 88
[2.64275]→[4.13634:13674](∅→∅),
[4.13634]→[4.13634:13674](∅→∅) − for _, label := range segment.Labels {
+ for _, label := range labels {
edit in tools/calls/calls_remove.go at line 96
[4.13851]→[4.13851:13896](∅→∅) − }
−
− if len(matches) == 0 {
− return nil, ""
edit in tools/calls/calls_remove.go at line 97
replacement in tools/calls/calls_remove.go at line 100
[4.13900]→[4.13900:14093](∅→∅) − if callType == "" && len(matches) > 1 {
− var callTypes []string
− for _, l := range matches {
− ct := l.CallType
− if ct == "" {
− ct = "(none)"
− }
− callTypes = append(callTypes, ct)
+ // 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)"
replacement in tools/calls/calls_remove.go at line 108
[4.14097]→[4.14097:14258](∅→∅) − return nil, fmt.Sprintf("multiple labels match species '%s' with filter '%s', specify --calltype to disambiguate (calltypes: %v)", species, filter, callTypes)
+ callTypes = append(callTypes, ct)
replacement in tools/calls/calls_remove.go at line 110
[4.14261]→[4.14261:14282](∅→∅) + return fmt.Sprintf("multiple labels match species '%s' with filter '%s', specify --calltype to disambiguate (calltypes: %v)", species, filter, callTypes)
replacement in tools/calls/calls_remove.go at line 238
[4.18426]→[4.18426:18590](∅→∅) − // If segment has no labels left, remove it
− if len(targetSegment.Labels) == 0 {
− removeSegmentFromDataFile(dataFile, targetSegment)
− output.Removed = "segment"
+ // Handle the result of label removal
+ output.Removed = handleRemovalResult(dataFile, targetSegment, input.File)
replacement in tools/calls/calls_remove.go at line 241
[4.18591]→[4.18591:18903](∅→∅) − // If no segments left, remove the .data file
− if len(dataFile.Segments) == 0 {
− if err := os.Remove(input.File); err != nil {
− return removeOutputError(&output, fmt.Sprintf("failed to remove file: %v", err))
− }
− output.Removed = "file"
− return output, nil
− }
− } else {
− output.Removed = "label"
+ // Handle file removal case
+ if output.Removed == "file" {
+ return output, nil
edit in tools/calls/calls_remove.go at line 251
+ }
+
+ // 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"
edit in tools/calls/calls_from_birda.go at line 108
+ }
+
+ idx := detectBirdaColumns(header)
+ if err := validateBirdaColumns(idx); err != nil {
+ return birdaColumnIndices{}, err
edit in tools/calls/calls_from_birda.go at line 114
edit in tools/calls/calls_from_birda.go at line 117
+ // detectBirdaColumns scans the header row and returns column indices.
+ func detectBirdaColumns(header []string) birdaColumnIndices {
edit in tools/calls/calls_from_birda.go at line 120
+ columnMap := map[string]*int{
+ "Start (s)": &idx.startIdx,
+ "End (s)": &idx.endIdx,
+ "Common name": &idx.commonNameIdx,
+ "Confidence": &idx.confidenceIdx,
+ "File": &idx.fileIdx,
+ }
+
replacement in tools/calls/calls_from_birda.go at line 130
[4.207257]→[4.207257:207475](∅→∅) − switch col {
− case "Start (s)":
− idx.startIdx = i
− case "End (s)":
− idx.endIdx = i
− case "Common name":
− idx.commonNameIdx = i
− case "Confidence":
− idx.confidenceIdx = i
− case "File":
− idx.fileIdx = i
+ if ptr, ok := columnMap[col]; ok {
+ *ptr = i
edit in tools/calls/calls_from_birda.go at line 134
edit in tools/calls/calls_from_birda.go at line 137
+ // validateBirdaColumns checks that required columns were found.
+ func validateBirdaColumns(idx birdaColumnIndices) error {
replacement in tools/calls/calls_from_birda.go at line 140
[4.207582]→[4.207582:207668](∅→∅) − return birdaColumnIndices{}, fmt.Errorf("missing required columns in BirdNET file")
+ return fmt.Errorf("missing required columns in BirdNET file")
replacement in tools/calls/calls_from_birda.go at line 142
[4.207671]→[4.207671:207688](∅→∅)