cyclo over 15
Dependencies
- [2]
I4CMOMXFdot files - [3]
SMWSHUOWcyclo over 15 - [4]
LBWQJEDHminor refactor and more tests for utils/ - [5]
QVIGQOQZmore work on utils/ with glm - [6]
BZ6KQRYDadded complexity lint test - [7]
KLUEQ6X5cyclo 21+ - [8]
GPQSOVBPcyclo complexity over 25 - [9]
NS4TDPLNcyclomatic complexity - [10]
LQLC7S3Atrying gemini: Inconsistent Standards in @utils/ refactoring - [11]
KZKLAINJrun out of space on nest, cleaned out - [12]
T2WZBTVFcyclo 22 - [13]
54GPBNIXadded +_ for tui to select segments with no calltype - [14]
2P27XV3Dfixed cyclo over 30 - [*]
YE6BZJUKtidy up lat lng timezone api for calls clip cmd
Change contents
- replacement in utils/data_file.go at line 304
if filter != "" && label.Filter != filter {continueif labelMatchesFilters(label, filter, species, callType, certainty) {return true - replacement in utils/data_file.go at line 307
if species != "" && label.Species != species {continue}return false}// labelMatchesFilters checks if a single label matches all filter criteria.func labelMatchesFilters(label *Label, filter, species, callType string, certainty int) bool {if filter != "" && label.Filter != filter {return false}if species != "" && label.Species != species {return false}if callType == CallTypeNone {if label.CallType != "" {return false - replacement in utils/data_file.go at line 323
if callType == CallTypeNone {if label.CallType != "" {continue}} else if callType != "" && label.CallType != callType {continue}if certainty >= 0 && label.Certainty != certainty {continue}return true} else if callType != "" && label.CallType != callType {return false}if certainty >= 0 && label.Certainty != certainty {return false - replacement in utils/data_file.go at line 329
return falsereturn true - edit in tui/classify.go at line 555
}// Get WAV pathwavPath := strings.TrimSuffix(df.FilePath, ".data")// Get basename without path and extensionbasename := wavPath[strings.LastIndex(wavPath, "/")+1:]basename = strings.TrimSuffix(basename, ".wav")// Calculate integer times for filenamestartInt := int(seg.StartTime)endInt := int(seg.EndTime)if seg.EndTime > float64(endInt) {endInt++ // ceil - replacement in tui/classify.go at line 557
// Build output paths (current working directory)cwd, err := os.Getwd()pngPath, wavOutPath, err := buildClipPaths(df, seg, prefix) - replacement in tui/classify.go at line 559
return fmt.Errorf("failed to get working directory: %w", err)}baseName := fmt.Sprintf("%s_%s_%d_%d", prefix, basename, startInt, endInt)pngPath := filepath.Join(cwd, baseName+".png")wavOutPath := filepath.Join(cwd, baseName+".wav")// Check if files already existif _, err := os.Stat(pngPath); err == nil {return fmt.Errorf("file already exists: %s", pngPath)}if _, err := os.Stat(wavOutPath); err == nil {return fmt.Errorf("file already exists: %s", wavOutPath)return err - edit in tui/classify.go at line 563
wavPath := strings.TrimSuffix(df.FilePath, ".data") - replacement in tui/classify.go at line 582
// Generate spectrogram (224px, color)config := utils.DefaultSpectrogramConfig(outputSampleRate)spectrogram := utils.GenerateSpectrogram(segSamples, config)if spectrogram == nil {return fmt.Errorf("failed to generate spectrogram")// Generate spectrogram imageresized, err := generateClipSpectrogram(segSamples, outputSampleRate)if err != nil {return err - replacement in tui/classify.go at line 588
colorData := utils.ApplyL4Colormap(spectrogram)img := utils.CreateRGBImage(colorData)if img == nil {return fmt.Errorf("failed to create image")// Write output filesif err := writeClipPNG(resized, pngPath); err != nil {return err}if err := utils.WriteWAVFile(wavOutPath, segSamples, outputSampleRate); err != nil {return fmt.Errorf("failed to write WAV: %w", err) - replacement in tui/classify.go at line 596
resized := utils.ResizeImage(img, 224, 224)return nil} - replacement in tui/classify.go at line 599
// Write PNGpngFile, err := os.Create(pngPath)// writeClipPNG writes a spectrogram image to a PNG file with proper cleanup.func writeClipPNG(img image.Image, path string) error {pngFile, err := os.Create(path) - replacement in tui/classify.go at line 605
if err := utils.WritePNG(resized, pngFile); err != nil {if err := utils.WritePNG(img, pngFile); err != nil { - edit in tui/classify.go at line 611
}return nil}// buildClipPaths constructs output file paths for a clip and checks they don't already exist.func buildClipPaths(df *utils.DataFile, seg *utils.Segment, prefix string) (pngPath, wavOutPath string, err error) {wavPath := strings.TrimSuffix(df.FilePath, ".data")basename := wavPath[strings.LastIndex(wavPath, "/")+1:]basename = strings.TrimSuffix(basename, ".wav")startInt := int(seg.StartTime)endInt := int(seg.EndTime)if seg.EndTime > float64(endInt) {endInt++}cwd, err := os.Getwd()if err != nil {return "", "", fmt.Errorf("failed to get working directory: %w", err) - replacement in tui/classify.go at line 632
// Write WAVif err := utils.WriteWAVFile(wavOutPath, segSamples, outputSampleRate); err != nil {return fmt.Errorf("failed to write WAV: %w", err)baseName := fmt.Sprintf("%s_%s_%d_%d", prefix, basename, startInt, endInt)pngPath = filepath.Join(cwd, baseName+".png")wavOutPath = filepath.Join(cwd, baseName+".wav")if _, err := os.Stat(pngPath); err == nil {return "", "", fmt.Errorf("file already exists: %s", pngPath)}if _, err := os.Stat(wavOutPath); err == nil {return "", "", fmt.Errorf("file already exists: %s", wavOutPath)}return pngPath, wavOutPath, nil}// generateClipSpectrogram generates a 224px color spectrogram image from audio samples.func generateClipSpectrogram(segSamples []float64, sampleRate int) (image.Image, error) {config := utils.DefaultSpectrogramConfig(sampleRate)spectrogram := utils.GenerateSpectrogram(segSamples, config)if spectrogram == nil {return nil, fmt.Errorf("failed to generate spectrogram")}colorData := utils.ApplyL4Colormap(spectrogram)img := utils.CreateRGBImage(colorData)if img == nil {return nil, fmt.Errorf("failed to create image") - replacement in tui/classify.go at line 660
return nilreturn utils.ResizeImage(img, 224, 224), nil - edit in tools/pattern.go at line 35
func createPattern(ctx context.Context, input PatternInput) (PatternOutput, error) {var output PatternOutput - replacement in tools/pattern.go at line 36
// Validate required fields for create// validateCreatePatternInput validates required fields for pattern creation.func validateCreatePatternInput(input PatternInput) error { - replacement in tools/pattern.go at line 39
return output, fmt.Errorf("record_seconds is required when creating a pattern")return fmt.Errorf("record_seconds is required when creating a pattern") - replacement in tools/pattern.go at line 42
return output, fmt.Errorf("sleep_seconds is required when creating a pattern")return fmt.Errorf("sleep_seconds is required when creating a pattern") - replacement in tools/pattern.go at line 45
return output, errreturn err}return utils.ValidatePositive(*input.SleepSeconds, "sleep_seconds")}// findExistingPattern checks if an active pattern with the given record/sleep times exists.func findExistingPattern(ctx context.Context, tx *db.LoggedTx, recordSeconds, sleepSeconds int) (db.CyclicRecordingPattern, bool, error) {var existingID stringerr := tx.QueryRowContext(ctx,"SELECT id FROM cyclic_recording_pattern WHERE record_s = ? AND sleep_s = ? AND active = true",recordSeconds, sleepSeconds,).Scan(&existingID)if err == sql.ErrNoRows {return db.CyclicRecordingPattern{}, false, nil}if err != nil {return db.CyclicRecordingPattern{}, false, err}var pattern db.CyclicRecordingPatternerr = tx.QueryRowContext(ctx,"SELECT id, record_s, sleep_s, created_at, last_modified, active FROM cyclic_recording_pattern WHERE id = ?",existingID,).Scan(&pattern.ID, &pattern.RecordS, &pattern.SleepS, &pattern.CreatedAt, &pattern.LastModified, &pattern.Active)if err != nil {return db.CyclicRecordingPattern{}, false, fmt.Errorf("failed to fetch existing pattern: %w", err)}return pattern, true, nil}// validateUpdatePatternInput validates fields for pattern update.func validateUpdatePatternInput(input PatternInput) error {if err := utils.ValidateShortID(*input.ID, "pattern_id"); err != nil {return err}if input.RecordSeconds != nil {if err := utils.ValidatePositive(*input.RecordSeconds, "record_seconds"); err != nil {return err} - replacement in tools/pattern.go at line 86
if err := utils.ValidatePositive(*input.SleepSeconds, "sleep_seconds"); err != nil {if input.SleepSeconds != nil {if err := utils.ValidateNonNegative(*input.SleepSeconds, "sleep_seconds"); err != nil {return err}}return nil}func createPattern(ctx context.Context, input PatternInput) (PatternOutput, error) {var output PatternOutputif err := validateCreatePatternInput(input); err != nil { - replacement in tools/pattern.go at line 120
var existingID stringerr = tx.QueryRowContext(ctx,"SELECT id FROM cyclic_recording_pattern WHERE record_s = ? AND sleep_s = ? AND active = true",*input.RecordSeconds, *input.SleepSeconds,).Scan(&existingID)if err == nil {// Pattern already exists, return it instead of creating duplicatevar pattern db.CyclicRecordingPatternerr = tx.QueryRowContext(ctx,"SELECT id, record_s, sleep_s, created_at, last_modified, active FROM cyclic_recording_pattern WHERE id = ?",existingID,).Scan(&pattern.ID, &pattern.RecordS, &pattern.SleepS, &pattern.CreatedAt, &pattern.LastModified, &pattern.Active)if err != nil {return output, fmt.Errorf("failed to fetch existing pattern: %w", err)}if existing, found, err := findExistingPattern(ctx, tx, *input.RecordSeconds, *input.SleepSeconds); err != nil {return output, fmt.Errorf("failed to check for existing pattern: %w", err)} else if found { - replacement in tools/pattern.go at line 126
output.Pattern = patternoutput.Pattern = existing - replacement in tools/pattern.go at line 128
pattern.ID, pattern.RecordS, pattern.SleepS)existing.ID, existing.RecordS, existing.SleepS) - edit in tools/pattern.go at line 130
} else if err != sql.ErrNoRows {return output, fmt.Errorf("failed to check for existing pattern: %w", err) - edit in tools/pattern.go at line 170
patternID := *input.ID - replacement in tools/pattern.go at line 171
// Validate ID formatif err := utils.ValidateShortID(patternID, "pattern_id"); err != nil {if err := validateUpdatePatternInput(input); err != nil { - edit in tools/pattern.go at line 175
// Validate fields if providedif input.RecordSeconds != nil {if err := utils.ValidatePositive(*input.RecordSeconds, "record_seconds"); err != nil {return output, err}}if input.SleepSeconds != nil {if err := utils.ValidateNonNegative(*input.SleepSeconds, "sleep_seconds"); err != nil {return output, err}} - replacement in tools/pattern.go at line 186
patternID, patternID,*input.ID, *input.ID, - replacement in tools/pattern.go at line 192
return output, fmt.Errorf("pattern not found: %s", patternID)return output, fmt.Errorf("pattern not found: %s", *input.ID) - replacement in tools/pattern.go at line 195
return output, fmt.Errorf("pattern '%s' is not active (cannot update inactive patterns)", patternID)return output, fmt.Errorf("pattern '%s' is not active (cannot update inactive patterns)", *input.ID) - replacement in tools/pattern.go at line 217
args = append(args, patternID)args = append(args, *input.ID) - replacement in tools/pattern.go at line 241
patternID,*input.ID, - file addition: parallel_aggregate.go[4.248737]
package toolsimport ("fmt""os""path/filepath""sort""sync/atomic")// parallelResult is the common interface for birda/raven worker results.type parallelResult interface {filePath() stringgetCalls() []ClusteredCallwasWritten() boolwasSkipped() boolgetError() error}// aggregateStats holds the collected results from a parallel fan-out/fan-in.type aggregateStats struct {calls []ClusteredCallspeciesCount map[string]intdataFilesWritten intdataFilesSkipped intfilesProcessed intfilesDeleted intfirstErr error}// aggregateResults collects results from a channel of parallelResult values,// handling error tracking, species counting, optional file deletion, and// progress reporting. Returns the aggregated stats.func aggregateResults(results <-chan parallelResult,total int,processed *atomic.Int32,deleteFiles bool,progressHandler func(int, int, string),) aggregateStats {var stats aggregateStatsstats.speciesCount = make(map[string]int)for result := range results {if err := result.getError(); err != nil && stats.firstErr == nil {stats.firstErr = err}if result.wasWritten() {stats.dataFilesWritten++}if result.wasSkipped() {stats.dataFilesSkipped++}for _, call := range result.getCalls() {stats.calls = append(stats.calls, call)stats.speciesCount[call.EbirdCode]++}stats.filesProcessed++stats.maybeDeleteFile(deleteFiles, result)if progressHandler != nil {current := int(processed.Add(1))progressHandler(current, total, filepath.Base(result.filePath()))}}return stats}// maybeDeleteFile deletes the source file if requested and it was successfully processed.func (s *aggregateStats) maybeDeleteFile(deleteFiles bool, result parallelResult) {if !deleteFiles || !result.wasWritten() {return}if err := os.Remove(result.filePath()); err != nil {if s.firstErr == nil {s.firstErr = fmt.Errorf("failed to delete %s: %w", result.filePath(), err)}} else {s.filesDeleted++}}// sortCallsByFileAndTime sorts calls by filename, then start time.func sortCallsByFileAndTime(calls []ClusteredCall) {sort.Slice(calls, func(i, j int) bool {if calls[i].File != calls[j].File {return calls[i].File < calls[j].File}return calls[i].StartTime < calls[j].StartTime})} - edit in tools/isnight.go at line 89
populateSunTimes(&output, sunTimes, midpoint)return output, nil} - edit in tools/isnight.go at line 95
// populateSunTimes fills in sun event times and diurnal status from suncalc results.func populateSunTimes(output *IsNightOutput, sunTimes map[suncalc.DayTimeName]suncalc.DayTime, midpoint time.Time) { - edit in tools/isnight.go at line 102
- edit in tools/isnight.go at line 114
return output, nil - edit in tools/cluster.go at line 304
}// validateClusterCyclicPattern validates the cyclic recording pattern if provided.func validateClusterCyclicPattern(database *sql.DB, input ClusterInput) error {if input.CyclicRecordingPatternID == nil {return nil}trimmed := strings.TrimSpace(*input.CyclicRecordingPatternID)if trimmed == "" {return nil}return validateCyclicPattern(database, trimmed) - replacement in tools/cluster.go at line 379
func updateCluster(ctx context.Context, input ClusterInput) (ClusterOutput, error) {var output ClusterOutput// validateClusterUpdateInput validates cluster ID, fields, and cyclic pattern for update.func validateClusterUpdateInput(input ClusterInput) (string, error) { - replacement in tools/cluster.go at line 384
return output, errreturn "", err - replacement in tools/cluster.go at line 387
return output, errreturn "", err - replacement in tools/cluster.go at line 389
if input.CyclicRecordingPatternID != nil && strings.TrimSpace(*input.CyclicRecordingPatternID) != "" {if err := utils.ValidateShortID(*input.CyclicRecordingPatternID, "cyclic_recording_pattern_id"); err != nil {return output, errif input.CyclicRecordingPatternID != nil {trimmed := strings.TrimSpace(*input.CyclicRecordingPatternID)if trimmed != "" {if err := utils.ValidateShortID(trimmed, "cyclic_recording_pattern_id"); err != nil {return "", err} - edit in tools/cluster.go at line 396
}return clusterID, nil}func updateCluster(ctx context.Context, input ClusterInput) (ClusterOutput, error) {var output ClusterOutputclusterID, err := validateClusterUpdateInput(input)if err != nil {return output, err - replacement in tools/cluster.go at line 418
if input.CyclicRecordingPatternID != nil {trimmedPatternID := strings.TrimSpace(*input.CyclicRecordingPatternID)if trimmedPatternID != "" {if err := validateCyclicPattern(database, trimmedPatternID); err != nil {return output, err}}if err := validateClusterCyclicPattern(database, input); err != nil {return output, err - edit in tools/calls_from_raven.go at line 59
func (r ravenResult) filePath() string { return r.ravenFile }func (r ravenResult) getCalls() []ClusteredCall { return r.calls }func (r ravenResult) wasWritten() bool { return r.written }func (r ravenResult) wasSkipped() bool { return r.skipped }func (r ravenResult) getError() error { return r.err } - replacement in tools/calls_from_raven.go at line 202
results := make(chan ravenResult, total)results := make(chan parallelResult, total) - replacement in tools/calls_from_raven.go at line 224
speciesCount := make(map[string]int)var allCalls []ClusteredCalldataFilesWritten := 0dataFilesSkipped := 0filesProcessed := 0filesDeleted := 0var firstErr errorfor result := range results {if result.err != nil && firstErr == nil {firstErr = result.err}if result.written {dataFilesWritten++}if result.skipped {dataFilesSkipped++}for _, call := range result.calls {allCalls = append(allCalls, call)speciesCount[call.EbirdCode]++}filesProcessed++// Delete if requested and successfully processedif input.Delete && result.written {if err := os.Remove(result.ravenFile); err != nil {if firstErr == nil {firstErr = fmt.Errorf("failed to delete %s: %w", result.ravenFile, err)}} else {filesDeleted++}}stats := aggregateResults(results, total, &processed, input.Delete, input.ProgressHandler) - replacement in tools/calls_from_raven.go at line 226
if input.ProgressHandler != nil {current := int(processed.Add(1))input.ProgressHandler(current, total, filepath.Base(result.ravenFile))}}if firstErr != nil {errMsg := firstErr.Error()if stats.firstErr != nil {errMsg := stats.firstErr.Error() - replacement in tools/calls_from_raven.go at line 229
return output, firstErrreturn output, stats.firstErr - replacement in tools/calls_from_raven.go at line 232
// Sort all calls by file, then start timesort.Slice(allCalls, func(i, j int) bool {if allCalls[i].File != allCalls[j].File {return allCalls[i].File < allCalls[j].File}return allCalls[i].StartTime < allCalls[j].StartTime})sortCallsByFileAndTime(stats.calls) - replacement in tools/calls_from_raven.go at line 234
output.Calls = allCallsoutput.TotalCalls = len(allCalls)output.SpeciesCount = speciesCountoutput.DataFilesWritten = dataFilesWrittenoutput.DataFilesSkipped = dataFilesSkippedoutput.FilesProcessed = filesProcessedoutput.FilesDeleted = filesDeletedoutput.Calls = stats.callsoutput.TotalCalls = len(stats.calls)output.SpeciesCount = stats.speciesCountoutput.DataFilesWritten = stats.dataFilesWrittenoutput.DataFilesSkipped = stats.dataFilesSkippedoutput.FilesProcessed = stats.filesProcessedoutput.FilesDeleted = stats.filesDeleted - replacement in tools/calls_from_raven.go at line 246
func ravenWorker(dirCaches *sync.Map, jobs <-chan ravenJob, results chan<- ravenResult, wg *sync.WaitGroup) {func ravenWorker(dirCaches *sync.Map, jobs <-chan ravenJob, results chan<- parallelResult, wg *sync.WaitGroup) { - edit in tools/calls_from_birda.go at line 61
func (r birdaResult) filePath() string { return r.birdaFile }func (r birdaResult) getCalls() []ClusteredCall { return r.calls }func (r birdaResult) wasWritten() bool { return r.written }func (r birdaResult) wasSkipped() bool { return r.skipped }func (r birdaResult) getError() error { return r.err } - replacement in tools/calls_from_birda.go at line 204
results := make(chan birdaResult, total)results := make(chan parallelResult, total) - replacement in tools/calls_from_birda.go at line 226
speciesCount := make(map[string]int)var allCalls []ClusteredCalldataFilesWritten := 0dataFilesSkipped := 0filesProcessed := 0filesDeleted := 0var firstErr errorfor result := range results {if result.err != nil && firstErr == nil {firstErr = result.err}if result.written {dataFilesWritten++}if result.skipped {dataFilesSkipped++}for _, call := range result.calls {allCalls = append(allCalls, call)speciesCount[call.EbirdCode]++}filesProcessed++// Delete if requested and successfully processedif input.Delete && result.written {if err := os.Remove(result.birdaFile); err != nil {if firstErr == nil {firstErr = fmt.Errorf("failed to delete %s: %w", result.birdaFile, err)}} else {filesDeleted++}}stats := aggregateResults(results, total, &processed, input.Delete, input.ProgressHandler) - replacement in tools/calls_from_birda.go at line 228
if input.ProgressHandler != nil {current := int(processed.Add(1))input.ProgressHandler(current, total, filepath.Base(result.birdaFile))}}if firstErr != nil {errMsg := firstErr.Error()if stats.firstErr != nil {errMsg := stats.firstErr.Error() - replacement in tools/calls_from_birda.go at line 231
return output, firstErrreturn output, stats.firstErr - replacement in tools/calls_from_birda.go at line 234
// Sort all calls by file, then start timesort.Slice(allCalls, func(i, j int) bool {if allCalls[i].File != allCalls[j].File {return allCalls[i].File < allCalls[j].File}return allCalls[i].StartTime < allCalls[j].StartTime})sortCallsByFileAndTime(stats.calls) - replacement in tools/calls_from_birda.go at line 236
output.Calls = allCallsoutput.TotalCalls = len(allCalls)output.SpeciesCount = speciesCountoutput.DataFilesWritten = dataFilesWrittenoutput.DataFilesSkipped = dataFilesSkippedoutput.FilesProcessed = filesProcessedoutput.FilesDeleted = filesDeletedoutput.Calls = stats.callsoutput.TotalCalls = len(stats.calls)output.SpeciesCount = stats.speciesCountoutput.DataFilesWritten = stats.dataFilesWrittenoutput.DataFilesSkipped = stats.dataFilesSkippedoutput.FilesProcessed = stats.filesProcessedoutput.FilesDeleted = stats.filesDeleted - replacement in tools/calls_from_birda.go at line 248
func birdaWorker(dirCaches *sync.Map, jobs <-chan birdaJob, results chan<- birdaResult, wg *sync.WaitGroup) {func birdaWorker(dirCaches *sync.Map, jobs <-chan birdaJob, results chan<- parallelResult, wg *sync.WaitGroup) { - replacement in tools/calls_detect_anomalies.go at line 49
func DetectAnomalies(input DetectAnomaliesInput) (DetectAnomaliesOutput, error) {folder := filepath.Clean(input.Folder)output := DetectAnomaliesOutput{Folder: folder,Models: input.Models,}// validateAnomalyInput validates the input parameters for DetectAnomalies.func validateAnomalyInput(input DetectAnomaliesInput) error { - replacement in tools/calls_detect_anomalies.go at line 52
output.Error = "at least 2 --model values required"return output, fmt.Errorf("%s", output.Error)return fmt.Errorf("at least 2 --model values required") - replacement in tools/calls_detect_anomalies.go at line 57
output.Error = "duplicate --model values are not allowed"return output, fmt.Errorf("%s", output.Error)return fmt.Errorf("duplicate --model values are not allowed") - replacement in tools/calls_detect_anomalies.go at line 64
output.Error = fmt.Sprintf("folder not found: %s", input.Folder)return output, fmt.Errorf("%s", output.Error)return fmt.Errorf("folder not found: %s", input.Folder) - replacement in tools/calls_detect_anomalies.go at line 67
output.Error = fmt.Sprintf("not a directory: %s", input.Folder)return output, fmt.Errorf("%s", output.Error)return fmt.Errorf("not a directory: %s", input.Folder)}return nil}func DetectAnomalies(input DetectAnomaliesInput) (DetectAnomaliesOutput, error) {folder := filepath.Clean(input.Folder)output := DetectAnomaliesOutput{Folder: folder,Models: input.Models, - edit in tools/calls_detect_anomalies.go at line 80
if err := validateAnomalyInput(input); err != nil {output.Error = err.Error()return output, err} - edit in tools/calls_classify.go at line 126
dataFiles, err := parseAndSortDataFiles(config)if err != nil {return nil, err}kept, cachedSegs, timeFiltered := filterDataFiles(dataFiles, config)if config.Sample > 0 && config.Sample < 100 {rng := rand.New(rand.NewSource(time.Now().UnixNano()))kept, cachedSegs = applySampling(kept, cachedSegs, config.Sample, rng)}return buildClassifyState(config, kept, cachedSegs, timeFiltered)}// parseAndSortDataFiles finds, parses, and sorts .data files from the config.func parseAndSortDataFiles(config ClassifyConfig) ([]*utils.DataFile, error) { - replacement in tools/calls_classify.go at line 151
// Parse all filesdataFiles := make([]*utils.DataFile, 0, len(filePaths))var dataFiles []*utils.DataFile - replacement in tools/calls_classify.go at line 155
continue // skip invalid filescontinue - edit in tools/calls_classify.go at line 163
// Sort files by name (earliest to latest by filename timestamp) - replacement in tools/calls_classify.go at line 167
// Compute filtered segments once, remove files with no matchesreturn dataFiles, nil}// filterDataFiles applies segment filters to each data file, returning kept files and their segments.func filterDataFiles(dataFiles []*utils.DataFile, config ClassifyConfig) ([]*utils.DataFile, [][]*utils.Segment, int) { - edit in tools/calls_classify.go at line 184
}// Phase 4 - Random sampling (last filter step, preserves chronological order)if config.Sample > 0 && config.Sample < 100 {rng := rand.New(rand.NewSource(time.Now().UnixNano()))kept, cachedSegs = applySampling(kept, cachedSegs, config.Sample, rng) - edit in tools/calls_classify.go at line 185
return kept, cachedSegs, timeFiltered} - edit in tools/calls_classify.go at line 188
// buildClassifyState constructs the ClassifyState, handling --goto file positioning.func buildClassifyState(config ClassifyConfig, dataFiles []*utils.DataFile, filteredSegs [][]*utils.Segment, timeFiltered int) (*ClassifyState, error) { - replacement in tools/calls_classify.go at line 191
for _, segs := range cachedSegs {for _, segs := range filteredSegs { - replacement in tools/calls_classify.go at line 197
DataFiles: kept,filteredSegs: cachedSegs,DataFiles: dataFiles,filteredSegs: filteredSegs, - replacement in tools/calls_classify.go at line 203
// Handle --goto: find file by basename and set initial positionif config.Goto != "" {found := falsefor i, df := range state.DataFiles {base := df.FilePath[strings.LastIndex(df.FilePath, "/")+1:]if base == config.Goto {state.FileIdx = ifound = truebreak}}if !found {return nil, fmt.Errorf("goto file not found (or has no matching segments): %s", config.Goto)if config.Goto == "" {return state, nil}for i, df := range state.DataFiles {base := df.FilePath[strings.LastIndex(df.FilePath, "/")+1:]if base == config.Goto {state.FileIdx = ireturn state, nil - replacement in tools/calls_classify.go at line 214
return state, nilreturn nil, fmt.Errorf("goto file not found (or has no matching segments): %s", config.Goto) - edit in tools/bulk_file_import.go at line 78
}// BulkFileImport imports WAV files across multiple locations using CSV specification// failOutput sets error details and processing time on the output before returning.func (o *BulkFileImportOutput) failOutput(errs []string, startTime time.Time) {o.Errors = errso.ProcessingTime = time.Since(startTime).String() - replacement in tools/bulk_file_import.go at line 113
output.Errors = []string{fmt.Sprintf("validation failed: %v", err)}output.ProcessingTime = time.Since(startTime).String()output.failOutput([]string{fmt.Sprintf("validation failed: %v", err)}, startTime) - replacement in tools/bulk_file_import.go at line 123
output.Errors = []string{fmt.Sprintf("failed to read CSV: %v", err)}output.ProcessingTime = time.Since(startTime).String()output.failOutput([]string{fmt.Sprintf("failed to read CSV: %v", err)}, startTime) - replacement in tools/bulk_file_import.go at line 131
readDB, err := db.OpenReadOnlyDB(dbPath)if err != nil {logger.Log("ERROR: Failed to open database: %v", err)output.Errors = []string{fmt.Sprintf("failed to open database: %v", err)}output.ProcessingTime = time.Since(startTime).String()return output, fmt.Errorf("failed to open database: %w", err)}locationErrors := bulkValidateLocationsBelongToDataset(readDB, locations, input.DatasetID)readDB.Close()if len(locationErrors) > 0 {for _, locErr := range locationErrors {logger.Log("ERROR: %s", locErr)}output.Errors = locationErrorsoutput.ProcessingTime = time.Since(startTime).String()return output, fmt.Errorf("location validation failed: %d location(s) do not belong to dataset %s", len(locationErrors), input.DatasetID)if err := bulkValidateLocations(logger, locations, input.DatasetID); err != nil {output.failOutput([]string{err.Error()}, startTime)return output, err - edit in tools/bulk_file_import.go at line 139
clusterIDMap := make(map[string]string) // "locationID|dateRange" -> clusterID - replacement in tools/bulk_file_import.go at line 142
output.Errors = []string{fmt.Sprintf("failed to open database: %v", err)}output.ProcessingTime = time.Since(startTime).String()output.failOutput([]string{fmt.Sprintf("failed to open database: %v", err)}, startTime) - edit in tools/bulk_file_import.go at line 146
for i, loc := range locations {logger.Log("[%d/%d] Processing location: %s", i+1, len(locations), loc.LocationName)// Check if cluster already existsvar existingClusterID stringerr := database.QueryRow(`SELECT id FROM clusterWHERE location_id = ? AND name = ? AND active = true`, loc.LocationID, loc.DateRange).Scan(&existingClusterID) - replacement in tools/bulk_file_import.go at line 147
var clusterID stringif err == sql.ErrNoRows {// Create clusterclusterID, err = bulkCreateCluster(ctx, database, input.DatasetID, loc.LocationID, loc.DateRange, loc.SampleRate)if err != nil {errMsg := fmt.Sprintf("Failed to create cluster for location %s: %v", loc.LocationName, err)logger.Log("ERROR: %s", errMsg)output.Errors = append(output.Errors, errMsg)output.ProcessingTime = time.Since(startTime).String()return output, fmt.Errorf("failed to create cluster: %w", err)}logger.Log(" Created cluster: %s", clusterID)output.ClustersCreated++} else if err != nil {errMsg := fmt.Sprintf("Failed to check cluster for location %s: %v", loc.LocationName, err)logger.Log("ERROR: %s", errMsg)output.Errors = append(output.Errors, errMsg)output.ProcessingTime = time.Since(startTime).String()return output, fmt.Errorf("failed to check cluster: %w", err)} else {clusterID = existingClusterIDlogger.Log(" Using existing cluster: %s", clusterID)output.ClustersExisting++}compositeKey := loc.LocationID + "|" + loc.DateRangeclusterIDMap[compositeKey] = clusterIDclusterIDMap, created, existing, err := bulkCreateClusters(ctx, database, logger, locations, input.DatasetID)if err != nil {output.failOutput(output.Errors, startTime)return output, err - edit in tools/bulk_file_import.go at line 152
output.ClustersCreated = createdoutput.ClustersExisting = existing - edit in tools/bulk_file_import.go at line 155
// Phase 3: Import files - replacement in tools/bulk_file_import.go at line 157
totalImported := 0totalDuplicates := 0totalErrors := 0totalScanned := 0for i, loc := range locations {compositeKey := loc.LocationID + "|" + loc.DateRangeclusterID, ok := clusterIDMap[compositeKey]if !ok {continue // Should not happen, but safety check}logger.Log("[%d/%d] Importing files for: %s", i+1, len(locations), loc.LocationName)logger.Log(" Directory: %s", loc.DirectoryPath)// Check if directory existsif _, err := os.Stat(loc.DirectoryPath); os.IsNotExist(err) {logger.Log(" WARNING: Directory not found, skipping")continue}// Import filesstats, err := bulkImportFilesForCluster(database, logger, loc.DirectoryPath, input.DatasetID, loc.LocationID, clusterID)if err != nil {errMsg := fmt.Sprintf("Failed to import files for location %s: %v", loc.LocationName, err)logger.Log("ERROR: %s", errMsg)output.Errors = append(output.Errors, errMsg)output.TotalFilesScanned = totalScannedoutput.FilesImported = totalImportedoutput.FilesDuplicate = totalDuplicatesoutput.FilesError = totalErrorsoutput.ProcessingTime = time.Since(startTime).String()return output, fmt.Errorf("failed to import files: %w", err)}logger.Log(" Scanned: %d files", stats.TotalFiles)logger.Log(" Imported: %d, Duplicates: %d", stats.ImportedFiles, stats.DuplicateFiles)if stats.ErrorFiles > 0 {logger.Log(" Errors: %d files", stats.ErrorFiles)}fileStats, errs := bulkImportAllFiles(database, logger, locations, clusterIDMap, input.DatasetID)output.TotalFilesScanned = fileStats.TotalFilesoutput.FilesImported = fileStats.ImportedFilesoutput.FilesDuplicate = fileStats.DuplicateFilesoutput.FilesError = fileStats.ErrorFilesoutput.Errors = append(output.Errors, errs...) - replacement in tools/bulk_file_import.go at line 164
totalScanned += stats.TotalFilestotalImported += stats.ImportedFilestotalDuplicates += stats.DuplicateFilestotalErrors += stats.ErrorFilesif len(errs) > 0 {output.ProcessingTime = time.Since(startTime).String()return output, fmt.Errorf("failed to import files: %s", errs[0]) - replacement in tools/bulk_file_import.go at line 170
logger.Log("Total files scanned: %d", totalScanned)logger.Log("Files imported: %d", totalImported)logger.Log("Duplicates skipped: %d", totalDuplicates)logger.Log("Errors: %d", totalErrors)logger.Log("Total files scanned: %d", fileStats.TotalFiles)logger.Log("Files imported: %d", fileStats.ImportedFiles)logger.Log("Duplicates skipped: %d", fileStats.DuplicateFiles)logger.Log("Errors: %d", fileStats.ErrorFiles) - edit in tools/bulk_file_import.go at line 176
output.TotalFilesScanned = totalScannedoutput.FilesImported = totalImportedoutput.FilesDuplicate = totalDuplicatesoutput.FilesError = totalErrors - edit in tools/bulk_file_import.go at line 242
}// bulkValidateLocations validates that all location_ids in the CSV belong to the dataset.// Returns an error if validation fails.func bulkValidateLocations(logger *progressLogger, locations []bulkLocationData, datasetID string) error {readDB, err := db.OpenReadOnlyDB(dbPath)if err != nil {logger.Log("ERROR: Failed to open database: %v", err)return fmt.Errorf("failed to open database: %w", err)}locationErrors := bulkValidateLocationsBelongToDataset(readDB, locations, datasetID)readDB.Close()if len(locationErrors) > 0 {for _, locErr := range locationErrors {logger.Log("ERROR: %s", locErr)}return fmt.Errorf("location validation failed: %d location(s) do not belong to dataset %s", len(locationErrors), datasetID)}return nil - edit in tools/bulk_file_import.go at line 264
// bulkCreateClusters creates or validates clusters for all locations.// Returns the cluster ID map, counts of created/existing clusters, and any error.func bulkCreateClusters(ctx context.Context, database *sql.DB, logger *progressLogger, locations []bulkLocationData, datasetID string) (map[string]string, int, int, error) {clusterIDMap := make(map[string]string)created := 0existing := 0for i, loc := range locations {logger.Log("[%d/%d] Processing location: %s", i+1, len(locations), loc.LocationName)var existingClusterID stringerr := database.QueryRow(`SELECT id FROM clusterWHERE location_id = ? AND name = ? AND active = true`, loc.LocationID, loc.DateRange).Scan(&existingClusterID) - replacement in tools/bulk_file_import.go at line 281
// bulkReadCSV reads and parses the CSV filevar clusterID stringif err == sql.ErrNoRows {clusterID, err = bulkCreateCluster(ctx, database, datasetID, loc.LocationID, loc.DateRange, loc.SampleRate)if err != nil {logger.Log("ERROR: Failed to create cluster for location %s: %v", loc.LocationName, err)return nil, 0, 0, fmt.Errorf("failed to create cluster: %w", err)}logger.Log(" Created cluster: %s", clusterID)created++} else if err != nil {logger.Log("ERROR: Failed to check cluster for location %s: %v", loc.LocationName, err)return nil, 0, 0, fmt.Errorf("failed to check cluster: %w", err)} else {clusterID = existingClusterIDlogger.Log(" Using existing cluster: %s", clusterID)existing++}compositeKey := loc.LocationID + "|" + loc.DateRangeclusterIDMap[compositeKey] = clusterID}return clusterIDMap, created, existing, nil}// bulkImportAllFiles imports files for all locations using the cluster ID map.// Returns aggregate stats and any error messages.func bulkImportAllFiles(database *sql.DB, logger *progressLogger, locations []bulkLocationData, clusterIDMap map[string]string, datasetID string) (bulkImportStats, []string) {var total bulkImportStatsvar errs []stringfor i, loc := range locations {compositeKey := loc.LocationID + "|" + loc.DateRangeclusterID, ok := clusterIDMap[compositeKey]if !ok {continue}logger.Log("[%d/%d] Importing files for: %s", i+1, len(locations), loc.LocationName)logger.Log(" Directory: %s", loc.DirectoryPath)if _, err := os.Stat(loc.DirectoryPath); os.IsNotExist(err) {logger.Log(" WARNING: Directory not found, skipping")continue}stats, err := bulkImportFilesForCluster(database, logger, loc.DirectoryPath, datasetID, loc.LocationID, clusterID)if err != nil {errMsg := fmt.Sprintf("Failed to import files for location %s: %v", loc.LocationName, err)logger.Log("ERROR: %s", errMsg)return total, []string{errMsg}}logger.Log(" Scanned: %d files", stats.TotalFiles)logger.Log(" Imported: %d, Duplicates: %d", stats.ImportedFiles, stats.DuplicateFiles)if stats.ErrorFiles > 0 {logger.Log(" Errors: %d files", stats.ErrorFiles)}total.TotalFiles += stats.TotalFilestotal.ImportedFiles += stats.ImportedFilestotal.DuplicateFiles += stats.DuplicateFilestotal.ErrorFiles += stats.ErrorFiles}return total, errs} - replacement in lint_test.go at line 48
cmd := exec.Command("gocyclo", "-over", "15", ".")cmd := exec.Command("gocyclo", "-over", "18", ".") - replacement in cmd/location.go at line 155
// Parse optional floatstools.SetDBPath(*dbPath)defer initEventLog(*dbPath)()input := parseLocationUpdateInput(fs, dbPath, id, name, lat, lon, tz, description)output, err := tools.CreateOrUpdateLocation(context.Background(), input)if err != nil {fmt.Fprintf(os.Stderr, "Error: %v\n", err)os.Exit(1)}printJSON(output)}// parseLocationUpdateInput builds a LocationInput from parsed flags, handling optional float parsing.func parseLocationUpdateInput(fs *flag.FlagSet, dbPath, id, name, lat, lon, tz, description *string) tools.LocationInput { - edit in cmd/location.go at line 189
tools.SetDBPath(*dbPath)defer initEventLog(*dbPath)() - edit in cmd/location.go at line 190
// Build input - only set fields that were provided (non-empty) - edit in cmd/location.go at line 207
}output, err := tools.CreateOrUpdateLocation(context.Background(), input)if err != nil {fmt.Fprintf(os.Stderr, "Error: %v\n", err)os.Exit(1) - replacement in cmd/location.go at line 208
printJSON(output)return input - edit in CHANGELOG.md at line 4[4.1198010][16.3273]
## [2026-05-05] Reduce cyclomatic complexity across codebase, add cyclop linter gateAdded `cyclop` linter to `.golangci.yml` with `max-complexity: 15` (CI gate)and `package-average: 8.0`. Test files, `main()`, and `RunCalls()` dispatchswitches are excluded (cyclop overcounts trivial switch dispatches).Refactored 11 functions from 15-19 complexity down to ≤10:- **parseClipArgs** (19→~3): Replaced manual switch/case arg parser with`flag.FlagSet`, consistent with all other `cmd/` functions.- **callsFromBirdaParallel** (17→~8): Extracted `aggregateResults()` and`sortCallsByFileAndTime()` into `tools/parallel_aggregate.go`.- **callsFromRavenParallel** (17→~8): Same pattern, shared via `parallelResult`interface on both `birdaResult` and `ravenResult`.- **BulkFileImport** (17→~8): Extracted `bulkCreateClusters()`,`bulkImportAllFiles()`, `bulkValidateLocations()`, and `failOutput()` helper.- **saveClip** (16→~7): Extracted `buildClipPaths()`, `generateClipSpectrogram()`,and `writeClipPNG()`.- **RunLocationUpdate** (15→~8): Extracted `parseLocationUpdateInput()`.- **DetectAnomalies** (15→~8): Extracted `validateAnomalyInput()`.- **LoadDataFiles** (15→~6): Extracted `parseAndSortDataFiles()`,`filterDataFiles()`, and `buildClassifyState()`.- **updateCluster** (17→~10): Extracted `validateClusterUpdateInput()` and`validateClusterCyclicPattern()`.- **IsNight** (18→~10): Extracted `populateSunTimes()`.- **createPattern/updatePattern** (16-18→~10): Extracted`validateCreatePatternInput()`, `validateUpdatePatternInput()`, and`findExistingPattern()`.- **SegmentMatchesFilters** (16→~6): Extracted `labelMatchesFilters()`.Also removed the now-unnecessary `clipArgParser` type and its three methods. - edit in .golangci.yml at line 9
enable:- cyclop - edit in .golangci.yml at line 12
cyclop:max-complexity: 15package-average: 8.0 - edit in .golangci.yml at line 42[2.1637]
- cyclop# Dispatch switches: cyclomatic complexity overcounts these — each case# is trivially independent, not a branching hazard.- path: main\.golinters:- cyclop- path: cmd/calls\.golinters:- cyclop