package tools
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"skraak/db"
"skraak/utils"
)
// ImportSegmentsInput defines the input parameters for the import_segments tool
type ImportSegmentsInput struct {
Folder string `json:"folder" jsonschema:"required,Path to folder containing .data files"`
Mapping string `json:"mapping" jsonschema:"required,Path to mapping JSON file"`
DatasetID string `json:"dataset_id" jsonschema:"required,Dataset ID (12-character nanoid)"`
LocationID string `json:"location_id" jsonschema:"required,Location ID (12-character nanoid)"`
ClusterID string `json:"cluster_id" jsonschema:"required,Cluster ID (12-character nanoid)"`
ProgressHandler func(processed, total int, message string)
}
// ImportSegmentsOutput defines the output structure for the import_segments tool
type ImportSegmentsOutput struct {
Summary ImportSegmentsSummary `json:"summary"`
Segments []SegmentImport `json:"segments"`
Errors []ImportSegmentError `json:"errors,omitempty"`
}
// ImportSegmentsSummary provides summary statistics for the import operation
type ImportSegmentsSummary struct {
DataFilesFound int `json:"data_files_found"`
DataFilesProcessed int `json:"data_files_processed"`
TotalSegments int `json:"total_segments"`
ImportedSegments int `json:"imported_segments"`
ImportedLabels int `json:"imported_labels"`
ImportedSubtypes int `json:"imported_subtypes"`
SkippedBookmarks int `json:"skipped_bookmarks"`
ProcessingTimeMs int64 `json:"processing_time_ms"`
}
// SegmentImport represents an imported segment in the output
type SegmentImport struct {
SegmentID string `json:"segment_id"`
FileName string `json:"file_name"`
StartTime float64 `json:"start_time"`
EndTime float64 `json:"end_time"`
FreqLow float64 `json:"freq_low"`
FreqHigh float64 `json:"freq_high"`
Labels []LabelImport `json:"labels"`
}
// LabelImport represents an imported label in the output
type LabelImport struct {
LabelID string `json:"label_id"`
Species string `json:"species"`
CallType string `json:"calltype,omitempty"`
Filter string `json:"filter"`
Certainty int `json:"certainty"`
Comment string `json:"comment,omitempty"`
}
// ImportSegmentError records errors encountered during segment import
type ImportSegmentError struct {
File string `json:"file,omitempty"`
Stage string `json:"stage"` // "validation", "hash", "import"
Message string `json:"message"`
}
// scannedDataFile holds parsed data for a .data file
type scannedDataFile struct {
DataPath string
WavPath string
WavHash string
FileID string
Duration float64
Segments []*utils.Segment
}
// ImportSegments imports segments from AviaNZ .data files into the database
func ImportSegments(ctx context.Context, input ImportSegmentsInput) (ImportSegmentsOutput, error) {
startTime := time.Now()
var output ImportSegmentsOutput
output.Segments = make([]SegmentImport, 0)
output.Errors = make([]ImportSegmentError, 0)
// Phase A: Input Validation
if err := validateSegmentImportInput(input); err != nil {
return output, err
}
// Load mapping file
mapping, err := utils.LoadMappingFile(input.Mapping)
if err != nil {
return output, fmt.Errorf("failed to load mapping file: %w", err)
}
// Find .data files
dataFiles, err := utils.FindDataFiles(input.Folder)
if err != nil {
return output, fmt.Errorf("failed to find .data files: %w", err)
}
output.Summary.DataFilesFound = len(dataFiles)
if len(dataFiles) == 0 {
return output, fmt.Errorf("no .data files found in folder: %s", input.Folder)
}
// Phase B: Parse all .data files and collect unique values
scannedFiles, parseErrors, uniqueFilters, uniqueSpecies, uniqueCalltypes := scanAllDataFiles(dataFiles, input.Folder)
output.Errors = append(output.Errors, parseErrors...)
if len(scannedFiles) == 0 {
output.Summary.ProcessingTimeMs = time.Since(startTime).Milliseconds()
return output, nil
}
// Phase C: Pre-Import Validation
database, err := db.OpenWriteableDB(dbPath)
if err != nil {
return output, fmt.Errorf("failed to open database: %w", err)
}
defer database.Close()
// Validate dataset/location/cluster hierarchy
if err := validateSegmentHierarchy(database, input.DatasetID, input.LocationID, input.ClusterID); err != nil {
return output, err
}
// Validate all filters exist
filterIDMap, err := validateFiltersExist(database, uniqueFilters)
if err != nil {
return output, fmt.Errorf("filter validation failed: %w", err)
}
// Validate mapping covers all species/calltypes and they exist in DB
validationResult, err := utils.ValidateMappingAgainstDB(database, mapping, uniqueSpecies, uniqueCalltypes)
if err != nil {
return output, fmt.Errorf("mapping validation failed: %w", err)
}
if validationResult.HasErrors() {
return output, fmt.Errorf("mapping validation failed: %s", validationResult.Error())
}
// Load species and calltype ID maps
speciesIDMap, calltypeIDMap, err := loadSpeciesCalltypeIDs(database, mapping, uniqueSpecies, uniqueCalltypes)
if err != nil {
return output, fmt.Errorf("failed to load species/calltype IDs: %w", err)
}
// Validate files: hash exists, no existing labels
fileIDMap, hashErrors := validateAndMapFiles(database, scannedFiles, input.ClusterID)
output.Errors = append(output.Errors, hashErrors...)
if len(fileIDMap) == 0 && len(scannedFiles) > 0 {
output.Summary.ProcessingTimeMs = time.Since(startTime).Milliseconds()
return output, nil
}
// Phase D: Transactional Import
importedSegments, importedLabels, importedSubtypes, skippedBookmarks, importErrors := importSegmentsIntoDB(
ctx, database, fileIDMap, scannedFiles, mapping, filterIDMap, speciesIDMap, calltypeIDMap, input.DatasetID, input.ProgressHandler,
)
output.Errors = append(output.Errors, importErrors...)
// Build output segments
for _, seg := range importedSegments {
output.Segments = append(output.Segments, seg)
}
output.Summary.DataFilesProcessed = len(fileIDMap)
output.Summary.TotalSegments = countTotalSegments(scannedFiles)
output.Summary.ImportedSegments = len(importedSegments)
output.Summary.ImportedLabels = importedLabels
output.Summary.ImportedSubtypes = importedSubtypes
output.Summary.SkippedBookmarks = skippedBookmarks
output.Summary.ProcessingTimeMs = time.Since(startTime).Milliseconds()
return output, nil
}
// validateSegmentImportInput validates input parameters
func validateSegmentImportInput(input ImportSegmentsInput) error {
// Validate folder exists
if info, err := os.Stat(input.Folder); err != nil {
return fmt.Errorf("folder does not exist: %s", input.Folder)
} else if !info.IsDir() {
return fmt.Errorf("path is not a folder: %s", input.Folder)
}
// Validate mapping file exists
if _, err := os.Stat(input.Mapping); err != nil {
return fmt.Errorf("mapping file does not exist: %s", input.Mapping)
}
// Validate IDs
if err := utils.ValidateShortID(input.DatasetID, "dataset_id"); err != nil {
return err
}
if err := utils.ValidateShortID(input.LocationID, "location_id"); err != nil {
return err
}
if err := utils.ValidateShortID(input.ClusterID, "cluster_id"); err != nil {
return err
}
return nil
}
// validateSegmentHierarchy validates dataset/location/cluster relationships
func validateSegmentHierarchy(dbConn *sql.DB, datasetID, locationID, clusterID string) error {
// Validate dataset exists and is structured
var datasetType string
err := dbConn.QueryRow(`SELECT type FROM dataset WHERE id = ? AND active = true`, datasetID).Scan(&datasetType)
if err == sql.ErrNoRows {
return fmt.Errorf("dataset not found: %s", datasetID)
}
if err != nil {
return fmt.Errorf("failed to query dataset: %w", err)
}
if datasetType != "structured" {
return fmt.Errorf("dataset must be 'structured' type, got: %s", datasetType)
}
// Validate location belongs to dataset
var locationExists bool
err = dbConn.QueryRow(`
SELECT EXISTS(SELECT 1 FROM location WHERE id = ? AND dataset_id = ? AND active = true)
`, locationID, datasetID).Scan(&locationExists)
if err != nil {
return fmt.Errorf("failed to query location: %w", err)
}
if !locationExists {
return fmt.Errorf("location not found or not linked to dataset: %s", locationID)
}
// Validate cluster belongs to location
var clusterExists bool
err = dbConn.QueryRow(`
SELECT EXISTS(SELECT 1 FROM cluster WHERE id = ? AND location_id = ? AND active = true)
`, clusterID, locationID).Scan(&clusterExists)
if err != nil {
return fmt.Errorf("failed to query cluster: %w", err)
}
if !clusterExists {
return fmt.Errorf("cluster not found or not linked to location: %s", clusterID)
}
return nil
}
// scanAllDataFiles parses all .data files and collects unique values
func scanAllDataFiles(dataFiles []string, folder string) (
[]scannedDataFile,
[]ImportSegmentError,
map[string]bool,
map[string]bool,
map[string]map[string]bool,
) {
var scanned []scannedDataFile
var errors []ImportSegmentError
uniqueFilters := make(map[string]bool)
uniqueSpecies := make(map[string]bool)
uniqueCalltypes := make(map[string]map[string]bool) // species -> calltype -> true
for _, dataPath := range dataFiles {
// Find corresponding WAV file
wavPath := strings.TrimSuffix(dataPath, ".data")
if _, err := os.Stat(wavPath); err != nil {
errors = append(errors, ImportSegmentError{
File: filepath.Base(dataPath),
Stage: "validation",
Message: fmt.Sprintf("corresponding WAV file not found: %s", filepath.Base(wavPath)),
})
continue
}
// Parse .data file
df, err := utils.ParseDataFile(dataPath)
if err != nil {
errors = append(errors, ImportSegmentError{
File: filepath.Base(dataPath),
Stage: "validation",
Message: fmt.Sprintf("failed to parse .data file: %v", err),
})
continue
}
// Collect unique filters, species, calltypes
for _, seg := range df.Segments {
for _, label := range seg.Labels {
uniqueFilters[label.Filter] = true
uniqueSpecies[label.Species] = true
if label.CallType != "" {
if uniqueCalltypes[label.Species] == nil {
uniqueCalltypes[label.Species] = make(map[string]bool)
}
uniqueCalltypes[label.Species][label.CallType] = true
}
}
}
scanned = append(scanned, scannedDataFile{
DataPath: dataPath,
WavPath: wavPath,
Duration: df.Meta.Duration,
Segments: df.Segments,
})
}
return scanned, errors, uniqueFilters, uniqueSpecies, uniqueCalltypes
}
// validateFiltersExist checks all filters exist in DB and returns ID map
func validateFiltersExist(dbConn *sql.DB, filterNames map[string]bool) (map[string]string, error) {
filterIDMap := make(map[string]string)
if len(filterNames) == 0 {
return filterIDMap, nil
}
names := make([]string, 0, len(filterNames))
for name := range filterNames {
names = append(names, name)
}
query := `SELECT id, name FROM filter WHERE name IN (` + placeholders(len(names)) + `) AND active = true`
args := make([]any, len(names))
for i, name := range names {
args[i] = name
}
rows, err := dbConn.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("failed to query filters: %w", err)
}
defer rows.Close()
for rows.Next() {
var id, name string
if err := rows.Scan(&id, &name); err == nil {
filterIDMap[name] = id
}
}
// Check for missing filters
var missing []string
for name := range filterNames {
if _, exists := filterIDMap[name]; !exists {
missing = append(missing, name)
}
}
if len(missing) > 0 {
return nil, fmt.Errorf("filters not found in database: [%s]", strings.Join(missing, ", "))
}
return filterIDMap, nil
}
// loadSpeciesCalltypeIDs loads species and calltype ID maps
func loadSpeciesCalltypeIDs(
dbConn *sql.DB,
mapping utils.MappingFile,
uniqueSpecies map[string]bool,
uniqueCalltypes map[string]map[string]bool,
) (map[string]string, map[string]map[string]string, error) {
speciesIDMap := make(map[string]string)
calltypeIDMap := make(map[string]map[string]string) // (dbSpecies, dbCalltype) -> calltype_id
// Collect all DB species labels from mapping
dbSpeciesSet := make(map[string]bool)
for dataSpecies := range uniqueSpecies {
if dbSpecies, ok := mapping.GetDBSpecies(dataSpecies); ok {
dbSpeciesSet[dbSpecies] = true
}
}
// Load species IDs
if len(dbSpeciesSet) > 0 {
dbSpeciesList := make([]string, 0, len(dbSpeciesSet))
for s := range dbSpeciesSet {
dbSpeciesList = append(dbSpeciesList, s)
}
query := `SELECT id, label FROM species WHERE label IN (` + placeholders(len(dbSpeciesList)) + `) AND active = true`
args := make([]any, len(dbSpeciesList))
for i, s := range dbSpeciesList {
args[i] = s
}
rows, err := dbConn.Query(query, args...)
if err != nil {
return nil, nil, fmt.Errorf("failed to query species: %w", err)
}
defer rows.Close()
for rows.Next() {
var id, label string
if err := rows.Scan(&id, &label); err == nil {
speciesIDMap[label] = id
}
}
}
// Load calltype IDs
for dataSpecies, ctSet := range uniqueCalltypes {
dbSpecies, ok := mapping.GetDBSpecies(dataSpecies)
if !ok {
continue
}
if calltypeIDMap[dbSpecies] == nil {
calltypeIDMap[dbSpecies] = make(map[string]string)
}
for dataCalltype := range ctSet {
dbCalltype := mapping.GetDBCalltype(dataSpecies, dataCalltype)
// Query calltype ID
var calltypeID string
err := dbConn.QueryRow(`
SELECT ct.id
FROM call_type ct
JOIN species s ON ct.species_id = s.id
WHERE s.label = ? AND ct.label = ? AND ct.active = true
`, dbSpecies, dbCalltype).Scan(&calltypeID)
if err == nil {
calltypeIDMap[dbSpecies][dbCalltype] = calltypeID
}
}
}
return speciesIDMap, calltypeIDMap, nil
}
// validateAndMapFiles validates files exist by hash and have no existing labels
func validateAndMapFiles(
dbConn *sql.DB,
scannedFiles []scannedDataFile,
clusterID string,
) (map[string]scannedDataFile, []ImportSegmentError) {
fileIDMap := make(map[string]scannedDataFile)
var errors []ImportSegmentError
for _, sf := range scannedFiles {
// Compute hash
hash, err := utils.ComputeXXH64(sf.WavPath)
if err != nil {
errors = append(errors, ImportSegmentError{
File: filepath.Base(sf.WavPath),
Stage: "hash",
Message: fmt.Sprintf("failed to compute hash: %v", err),
})
continue
}
sf.WavHash = hash
// Find file by hash in cluster
var fileID string
var duration float64
err = dbConn.QueryRow(`
SELECT id, duration FROM file WHERE xxh64_hash = ? AND cluster_id = ? AND active = true
`, hash, clusterID).Scan(&fileID, &duration)
if err == sql.ErrNoRows {
errors = append(errors, ImportSegmentError{
File: filepath.Base(sf.WavPath),
Stage: "validation",
Message: fmt.Sprintf("file hash not found in database for cluster (hash: %s)", hash),
})
continue
}
if err != nil {
errors = append(errors, ImportSegmentError{
File: filepath.Base(sf.WavPath),
Stage: "validation",
Message: fmt.Sprintf("failed to query file: %v", err),
})
continue
}
sf.FileID = fileID
sf.Duration = duration
// Check no existing labels for this file
var labelCount int
err = dbConn.QueryRow(`
SELECT COUNT(*) FROM label l
JOIN segment s ON l.segment_id = s.id
WHERE s.file_id = ? AND l.active = true
`, fileID).Scan(&labelCount)
if err != nil {
errors = append(errors, ImportSegmentError{
File: filepath.Base(sf.WavPath),
Stage: "validation",
Message: fmt.Sprintf("failed to check existing labels: %v", err),
})
continue
}
if labelCount > 0 {
errors = append(errors, ImportSegmentError{
File: filepath.Base(sf.WavPath),
Stage: "validation",
Message: fmt.Sprintf("file already has %d label(s) - fresh imports only", labelCount),
})
continue
}
fileIDMap[fileID] = sf
}
return fileIDMap, errors
}
// importSegmentsIntoDB performs the transactional import
func importSegmentsIntoDB(
ctx context.Context,
database *sql.DB,
fileIDMap map[string]scannedDataFile,
scannedFiles []scannedDataFile,
mapping utils.MappingFile,
filterIDMap map[string]string,
speciesIDMap map[string]string,
calltypeIDMap map[string]map[string]string,
datasetID string,
progressHandler func(processed, total int, message string),
) ([]SegmentImport, int, int, int, []ImportSegmentError) {
var importedSegments []SegmentImport
var errors []ImportSegmentError
importedLabels := 0
importedSubtypes := 0
skippedBookmarks := 0
// Begin transaction
tx, err := db.BeginLoggedTx(ctx, database, "import_segments")
if err != nil {
errors = append(errors, ImportSegmentError{
Stage: "import",
Message: fmt.Sprintf("failed to begin transaction: %v", err),
})
return nil, 0, 0, 0, errors
}
defer tx.Rollback()
// Process each file
totalFiles := len(scannedFiles)
processedFiles := 0
for _, sf := range scannedFiles {
if sf.FileID == "" {
continue // Was filtered out during validation
}
processedFiles++
if progressHandler != nil {
progressHandler(processedFiles, totalFiles, filepath.Base(sf.DataPath))
}
// Update file_metadata with skraak_hash
_, err = tx.ExecContext(ctx, `
INSERT INTO file_metadata (file_id, json, created_at, last_modified, active)
VALUES (?, json('{"skraak_hash": "' || ? || '"}'), now(), now(), true)
ON CONFLICT (file_id) DO UPDATE SET
json = json_set(COALESCE(json, '{}'), '$.skraak_hash', ?),
last_modified = now(),
active = true
`, sf.FileID, sf.WavHash, sf.WavHash)
if err != nil {
errors = append(errors, ImportSegmentError{
File: filepath.Base(sf.DataPath),
Stage: "import",
Message: fmt.Sprintf("failed to update file_metadata: %v", err),
})
continue
}
// Process segments
for _, seg := range sf.Segments {
// Check for bookmarks - skip entire segment if any label is bookmarked
hasBookmark := false
for _, label := range seg.Labels {
if label.Bookmark {
hasBookmark = true
break
}
}
if hasBookmark {
skippedBookmarks++
continue
}
// Validate segment bounds
if seg.StartTime >= seg.EndTime {
errors = append(errors, ImportSegmentError{
File: filepath.Base(sf.DataPath),
Stage: "import",
Message: fmt.Sprintf("invalid segment bounds: start=%.2f >= end=%.2f", seg.StartTime, seg.EndTime),
})
continue
}
if seg.EndTime > sf.Duration {
errors = append(errors, ImportSegmentError{
File: filepath.Base(sf.DataPath),
Stage: "import",
Message: fmt.Sprintf("segment end time (%.2f) exceeds file duration (%.2f)", seg.EndTime, sf.Duration),
})
continue
}
// Insert segment
segmentID, err := utils.GenerateLongID()
if err != nil {
errors = append(errors, ImportSegmentError{
File: filepath.Base(sf.DataPath),
Stage: "import",
Message: fmt.Sprintf("failed to generate segment ID: %v", err),
})
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO segment (id, file_id, dataset_id, start_time, end_time, freq_low, freq_high, created_at, last_modified, active)
VALUES (?, ?, ?, ?, ?, ?, ?, now(), now(), true)
`, segmentID, sf.FileID, datasetID, seg.StartTime, seg.EndTime, seg.FreqLow, seg.FreqHigh)
if err != nil {
errors = append(errors, ImportSegmentError{
File: filepath.Base(sf.DataPath),
Stage: "import",
Message: fmt.Sprintf("failed to insert segment: %v", err),
})
continue
}
// Process labels
var segmentImport SegmentImport
segmentImport.SegmentID = segmentID
segmentImport.FileName = filepath.Base(sf.WavPath)
segmentImport.StartTime = seg.StartTime
segmentImport.EndTime = seg.EndTime
segmentImport.FreqLow = seg.FreqLow
segmentImport.FreqHigh = seg.FreqHigh
segmentImport.Labels = make([]LabelImport, 0)
for _, label := range seg.Labels {
// Get DB species and calltype
dbSpecies, ok := mapping.GetDBSpecies(label.Species)
if !ok {
errors = append(errors, ImportSegmentError{
File: filepath.Base(sf.DataPath),
Stage: "import",
Message: fmt.Sprintf("species not found in mapping: %s", label.Species),
})
continue
}
speciesID, ok := speciesIDMap[dbSpecies]
if !ok {
errors = append(errors, ImportSegmentError{
File: filepath.Base(sf.DataPath),
Stage: "import",
Message: fmt.Sprintf("species ID not found: %s", dbSpecies),
})
continue
}
filterID, ok := filterIDMap[label.Filter]
if !ok {
errors = append(errors, ImportSegmentError{
File: filepath.Base(sf.DataPath),
Stage: "import",
Message: fmt.Sprintf("filter ID not found: %s", label.Filter),
})
continue
}
// Insert label
labelID, err := utils.GenerateLongID()
if err != nil {
errors = append(errors, ImportSegmentError{
File: filepath.Base(sf.DataPath),
Stage: "import",
Message: fmt.Sprintf("failed to generate label ID: %v", err),
})
continue
}
_, 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 {
errors = append(errors, ImportSegmentError{
File: filepath.Base(sf.DataPath),
Stage: "import",
Message: fmt.Sprintf("failed to insert label: %v", err),
})
continue
}
importedLabels++
// Insert label_metadata
metadataJSON := fmt.Sprintf(`{"skraak_label_id": "%s"`, labelID)
if label.Comment != "" {
// Escape quotes in comment
escapedComment := strings.ReplaceAll(label.Comment, `"`, `\"`)
metadataJSON += fmt.Sprintf(`, "comment": "%s"`, escapedComment)
}
metadataJSON += "}"
_, 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 {
errors = append(errors, ImportSegmentError{
File: filepath.Base(sf.DataPath),
Stage: "import",
Message: fmt.Sprintf("failed to insert label_metadata: %v", err),
})
continue
}
// Build label import for output
labelImport := LabelImport{
LabelID: labelID,
Species: dbSpecies,
Filter: label.Filter,
Certainty: label.Certainty,
}
if label.Comment != "" {
labelImport.Comment = label.Comment
}
// Insert label_subtype if calltype exists
if label.CallType != "" {
dbCalltype := mapping.GetDBCalltype(label.Species, label.CallType)
calltypeID := ""
if calltypeIDMap[dbSpecies] != nil {
calltypeID = calltypeIDMap[dbSpecies][dbCalltype]
}
if calltypeID == "" {
errors = append(errors, ImportSegmentError{
File: filepath.Base(sf.DataPath),
Stage: "import",
Message: fmt.Sprintf("calltype ID not found: %s/%s", dbSpecies, dbCalltype),
})
continue
}
subtypeID, err := utils.GenerateLongID()
if err != nil {
errors = append(errors, ImportSegmentError{
File: filepath.Base(sf.DataPath),
Stage: "import",
Message: fmt.Sprintf("failed to generate label_subtype ID: %v", err),
})
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO label_subtype (id, label_id, calltype_id, filter_id, certainty, created_at, last_modified, active)
VALUES (?, ?, ?, ?, ?, now(), now(), true)
`, subtypeID, labelID, calltypeID, filterID, label.Certainty)
if err != nil {
errors = append(errors, ImportSegmentError{
File: filepath.Base(sf.DataPath),
Stage: "import",
Message: fmt.Sprintf("failed to insert label_subtype: %v", err),
})
continue
}
importedSubtypes++
labelImport.CallType = dbCalltype
}
segmentImport.Labels = append(segmentImport.Labels, labelImport)
}
if len(segmentImport.Labels) > 0 {
importedSegments = append(importedSegments, segmentImport)
}
}
}
// Commit transaction
if err := tx.Commit(); err != nil {
errors = append(errors, ImportSegmentError{
Stage: "import",
Message: fmt.Sprintf("failed to commit transaction: %v", err),
})
return nil, 0, 0, skippedBookmarks, errors
}
return importedSegments, importedLabels, importedSubtypes, skippedBookmarks, errors
}
// countTotalSegments counts total non-bookmarked segments
func countTotalSegments(scannedFiles []scannedDataFile) int {
count := 0
for _, sf := range scannedFiles {
for _, seg := range sf.Segments {
hasBookmark := false
for _, label := range seg.Labels {
if label.Bookmark {
hasBookmark = true
break
}
}
if !hasBookmark {
count++
}
}
}
return count
}