DBOROCRFD6A5SJBMFYFEJI5S5M77X4EFEK6KDQWA5QDMQJKIHRWQC 3JA7HYRMHV57SIMGMGPDOMKQ3NBQS2SKOX3EKDHRBQRP7ZPZGFTQC IFVRAERTCCDICNTYTG3TX2WASB6RXQQEJWWXQMQZJSQDQ3HLE5OQC CDT7NGOU3VIKCP3YPZTXKOMLV54VERP4FFRVSRPVKMU3IQTI76CAC PZHNIV62T77A3VPGPAYURINYRMUJKMNQHTHYD7L22X7WDZSSKQ7QC 7NS27QXZMVTZBK4VPMYL5IKGSTTAWR6NDG5SOVITNX44VNIRZPMAC D4EL6RSTSZ3S3IDSETRNGLJHZKGZEE2V2OZIOKQK6LRLHQNS77JQC package utilsimport ("os""testing")func TestDataFileParse(t *testing.T) {// Create a test .data filecontent := `[{"Operator": "Auto", "Reviewer": null, "Duration": 60.0},[10.0, 20.0, 0, 0, [{"species": "Kiwi", "certainty": 70, "filter": "test-filter"}]],[30.0, 40.0, 1000, 5000, [{"species": "Morepork", "certainty": 80, "filter": "M"}]]]`tmpfile, err := os.CreateTemp("", "test*.data")if err != nil {t.Fatal(err)}defer os.Remove(tmpfile.Name())if _, err := tmpfile.Write([]byte(content)); err != nil {t.Fatal(err)}tmpfile.Close()// Parsedf, err := ParseDataFile(tmpfile.Name())if err != nil {t.Fatal(err)}// Check metadataif df.Meta.Operator != "Auto" {t.Errorf("expected Operator=Auto, got %s", df.Meta.Operator)}if df.Meta.Duration != 60.0 {t.Errorf("expected Duration=60.0, got %f", df.Meta.Duration)}// Check segmentsif len(df.Segments) != 2 {t.Errorf("expected 2 segments, got %d", len(df.Segments))}// Check first segment (sorted by start time)if df.Segments[0].StartTime != 10.0 {t.Errorf("expected StartTime=10.0, got %f", df.Segments[0].StartTime)}if df.Segments[0].EndTime != 20.0 {t.Errorf("expected EndTime=20.0, got %f", df.Segments[0].EndTime)}// Check labelsif len(df.Segments[0].Labels) != 1 {t.Errorf("expected 1 label, got %d", len(df.Segments[0].Labels))}if df.Segments[0].Labels[0].Species != "Kiwi" {t.Errorf("expected Species=Kiwi, got %s", df.Segments[0].Labels[0].Species)}if df.Segments[0].Labels[0].Certainty != 70 {t.Errorf("expected Certainty=70, got %d", df.Segments[0].Labels[0].Certainty)}}func TestDataFileWrite(t *testing.T) {df := &DataFile{FilePath: "",Meta: &DataMeta{Operator: "Test",Reviewer: "David",Duration: 120.0,},Segments: []*Segment{{StartTime: 5.0,EndTime: 15.0,FreqLow: 0,FreqHigh: 0,Labels: []*Label{{Species: "Kiwi", Certainty: 100, Filter: "test"},},},},}tmpfile, err := os.CreateTemp("", "test*.data")if err != nil {t.Fatal(err)}tmpfile.Close()defer os.Remove(tmpfile.Name())// Writeif err := df.Write(tmpfile.Name()); err != nil {t.Fatal(err)}// Re-parse and verifydf2, err := ParseDataFile(tmpfile.Name())if err != nil {t.Fatal(err)}if df2.Meta.Reviewer != "David" {t.Errorf("expected Reviewer=David, got %s", df2.Meta.Reviewer)}if len(df2.Segments) != 1 {t.Errorf("expected 1 segment, got %d", len(df2.Segments))}if df2.Segments[0].Labels[0].Species != "Kiwi" {t.Errorf("expected Species=Kiwi, got %s", df2.Segments[0].Labels[0].Species)}}func TestHasFilterLabel(t *testing.T) {seg := &Segment{Labels: []*Label{{Species: "Kiwi", Filter: "test-filter"},{Species: "Morepork", Filter: "M"},},}if !seg.HasFilterLabel("test-filter") {t.Error("expected HasFilterLabel(test-filter)=true")}if !seg.HasFilterLabel("M") {t.Error("expected HasFilterLabel(M)=true")}if seg.HasFilterLabel("other") {t.Error("expected HasFilterLabel(other)=false")}if !seg.HasFilterLabel("") {t.Error("expected HasFilterLabel('')=true (no filter)")}}func TestGetFilterLabels(t *testing.T) {seg := &Segment{Labels: []*Label{{Species: "Kiwi", Filter: "test-filter", Certainty: 70},{Species: "Morepork", Filter: "M", Certainty: 80},{Species: "Don't Know", Filter: "test-filter", Certainty: 0},},}labels := seg.GetFilterLabels("test-filter")if len(labels) != 2 {t.Errorf("expected 2 labels, got %d", len(labels))}labels = seg.GetFilterLabels("")if len(labels) != 3 {t.Errorf("expected 3 labels (no filter), got %d", len(labels))}}
package utilsimport ("encoding/json""fmt""os""sort""strings")// DataFile represents an AviaNZ .data filetype DataFile struct {Meta *DataMetaSegments []*SegmentFilePath string}// DataMeta contains metadata for a .data filetype DataMeta struct {Operator stringReviewer stringDuration float64Extra map[string]any // preserve unknown fields}// Segment represents a detection segmenttype Segment struct {StartTime float64EndTime float64FreqLow float64FreqHigh float64Labels []*Label}// Label represents a species label within a segmenttype Label struct {Species stringCertainty intFilter stringCallType stringExtra map[string]any // preserve unknown fields}// ParseDataFile reads and parses a .data filefunc ParseDataFile(path string) (*DataFile, error) {data, err := os.ReadFile(path)if err != nil {return nil, err}var raw []json.RawMessageif err := json.Unmarshal(data, &raw); err != nil {return nil, fmt.Errorf("parse JSON: %w", err)}if len(raw) == 0 {return nil, fmt.Errorf("empty .data file")}df := &DataFile{FilePath: path,Segments: make([]*Segment, 0, len(raw)-1),}// Parse metadata (first element)df.Meta = parseMeta(raw[0])// Parse segmentsfor i := 1; i < len(raw); i++ {seg, err := parseSegment(raw[i])if err != nil {continue // skip invalid segments}df.Segments = append(df.Segments, seg)}// Sort segments by start timesort.Slice(df.Segments, func(i, j int) bool {return df.Segments[i].StartTime < df.Segments[j].StartTime})return df, nil}// parseMeta parses the metadata objectfunc parseMeta(raw json.RawMessage) *DataMeta {var obj map[string]anyif err := json.Unmarshal(raw, &obj); err != nil {return &DataMeta{}}meta := &DataMeta{Extra: make(map[string]any)}if v, ok := obj["Operator"].(string); ok {meta.Operator = vdelete(obj, "Operator")}if v, ok := obj["Reviewer"].(string); ok {meta.Reviewer = vdelete(obj, "Reviewer")}if v, ok := obj["Duration"].(float64); ok {meta.Duration = vdelete(obj, "Duration")}// Store remaining fieldsfor k, v := range obj {meta.Extra[k] = v}return meta}// parseSegment parses a segment arrayfunc parseSegment(raw json.RawMessage) (*Segment, error) {var arr []json.RawMessageif err := json.Unmarshal(raw, &arr); err != nil {return nil, err}if len(arr) < 5 {return nil, fmt.Errorf("segment too short")}seg := &Segment{}// Parse time and frequencyif v, err := parseFloat(arr[0]); err == nil {seg.StartTime = v}if v, err := parseFloat(arr[1]); err == nil {seg.EndTime = v}if v, err := parseFloat(arr[2]); err == nil {seg.FreqLow = v}if v, err := parseFloat(arr[3]); err == nil {seg.FreqHigh = v}// Parse labelsvar labelArr []json.RawMessageif err := json.Unmarshal(arr[4], &labelArr); err == nil {for _, labelRaw := range labelArr {if label := parseLabel(labelRaw); label != nil {seg.Labels = append(seg.Labels, label)}}}// Sort labels alphabetically by speciessort.Slice(seg.Labels, func(i, j int) bool {return seg.Labels[i].Species < seg.Labels[j].Species})return seg, nil}// parseLabel parses a label objectfunc parseLabel(raw json.RawMessage) *Label {var obj map[string]anyif err := json.Unmarshal(raw, &obj); err != nil {return nil}label := &Label{Extra: make(map[string]any)}if v, ok := obj["species"].(string); ok {label.Species = vdelete(obj, "species")}if v, ok := obj["certainty"].(float64); ok {label.Certainty = int(v)delete(obj, "certainty")}if v, ok := obj["filter"].(string); ok {label.Filter = vdelete(obj, "filter")}if v, ok := obj["calltype"].(string); ok {label.CallType = vdelete(obj, "calltype")}// Store remaining fieldsfor k, v := range obj {label.Extra[k] = v}return label}// parseFloat extracts a float from JSONfunc parseFloat(raw json.RawMessage) (float64, error) {var v float64err := json.Unmarshal(raw, &v)return v, err}// WriteDataFile writes a DataFile back to diskfunc (df *DataFile) Write(path string) error {var raw []any// Build metadatameta := make(map[string]any)if df.Meta.Operator != "" {meta["Operator"] = df.Meta.Operator}if df.Meta.Reviewer != "" {meta["Reviewer"] = df.Meta.Reviewer}if df.Meta.Duration > 0 {meta["Duration"] = df.Meta.Duration}for k, v := range df.Meta.Extra {meta[k] = v}raw = append(raw, meta)// Build segmentsfor _, seg := range df.Segments {labels := make([]any, 0, len(seg.Labels))for _, label := range seg.Labels {l := make(map[string]any)l["species"] = label.Speciesl["certainty"] = label.Certaintyif label.Filter != "" {l["filter"] = label.Filter}if label.CallType != "" {l["calltype"] = label.CallType}for k, v := range label.Extra {l[k] = v}labels = append(labels, l)}segArr := []any{seg.StartTime,seg.EndTime,seg.FreqLow,seg.FreqHigh,labels,}raw = append(raw, segArr)}data, err := json.MarshalIndent(raw, "", " ")if err != nil {return err}return os.WriteFile(path, data, 0644)}// HasFilterLabel returns true if segment has a label matching the filterfunc (s *Segment) HasFilterLabel(filter string) bool {if filter == "" {return true}for _, label := range s.Labels {if label.Filter == filter {return true}}return false}// GetFilterLabels returns labels matching the filterfunc (s *Segment) GetFilterLabels(filter string) []*Label {var result []*Labelfor _, label := range s.Labels {if filter == "" || label.Filter == filter {result = append(result, label)}}return result}// FindDataFiles finds all .data files in a folderfunc FindDataFiles(folder string) ([]string, error) {var files []stringentries, err := os.ReadDir(folder)if err != nil {return nil, err}for _, entry := range entries {name := entry.Name()if strings.HasSuffix(name, ".data") {files = append(files, folder+"/"+name)}}return files, nil}
package tuiimport ("fmt""strings""time"tea "github.com/charmbracelet/bubbletea""github.com/charmbracelet/lipgloss""skraak/tools""skraak/utils")// Stylesvar (titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")).Background(lipgloss.Color("62")).Padding(0, 1)labelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("86"))keyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Bold(true)errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")))// Messagestype keyMsg stringtype tickMsg struct{}// Model holds TUI statetype Model struct {state *tools.ClassifyStatekeyBuffer stringlastKey time.Timeerr stringquitting bool}// New creates a new TUI modelfunc New(state *tools.ClassifyState) Model {return Model{state: state}}// Init initializes the modelfunc (m Model) Init() tea.Cmd {return tea.Batch(tickCmd(),)}func tickCmd() tea.Cmd {return tea.Tick(100*time.Millisecond, func(time.Time) tea.Msg {return tickMsg{}})}// Update handles messagesfunc (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {switch msg := msg.(type) {case tea.KeyMsg:return m.handleKey(msg)case tickMsg:// Check for key buffer timeoutif m.keyBuffer != "" && time.Since(m.lastKey) > time.Duration(m.state.Config.KeyDelay*float64(time.Second)) {// Try to parse and apply bindingif result := m.state.ParseKeyBuffer(m.keyBuffer); result != nil {m.state.ApplyBinding(result)if err := m.state.Save(); err != nil {m.err = err.Error()}m.keyBuffer = ""m.moveToNext()} else {m.err = fmt.Sprintf("Unknown binding: %s", m.keyBuffer)m.keyBuffer = ""}}return m, tickCmd()}return m, nil}func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {// Clear error on any keym.err = ""switch msg.String() {case "q", "ctrl+c":m.quitting = truereturn m, tea.Quitcase ",":// Previous segmentm.keyBuffer = ""m.state.PrevSegment()return m, nilcase ".":// Next segment (no edit)m.keyBuffer = ""m.moveToNext()return m, nildefault:// Add to key bufferif len(msg.String()) == 1 && isAlphaNum(msg.String()[0]) {m.keyBuffer += msg.String()m.lastKey = time.Now()// If 2 chars, process immediatelyif len(m.keyBuffer) >= 2 {if result := m.state.ParseKeyBuffer(m.keyBuffer); result != nil {m.state.ApplyBinding(result)if err := m.state.Save(); err != nil {m.err = err.Error()}m.keyBuffer = ""m.moveToNext()} else {m.err = fmt.Sprintf("Unknown binding: %s", m.keyBuffer)m.keyBuffer = ""}}return m, tickCmd()}}return m, nil}func (m Model) moveToNext() {if !m.state.NextSegment() {m.quitting = true}}func isAlphaNum(c byte) bool {return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')}// View renders the TUIfunc (m Model) View() string {if m.quitting {return "\nDone!\n"}var b strings.Builder// Header: file infodf := m.state.CurrentFile()seg := m.state.CurrentSegment()total := m.state.TotalSegments()current := m.state.CurrentSegmentNumber()if df == nil || seg == nil {return "\nNo segments to review.\n"}// Progress barprogress := float64(current) / float64(total)barWidth := 30filled := int(progress * float64(barWidth))bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)// Title linewavFile := strings.TrimSuffix(df.FilePath, ".data")wavFile = wavFile[strings.LastIndex(wavFile, "/")+1:]b.WriteString(titleStyle.Render(fmt.Sprintf(" %s [%s] %d/%d ", wavFile, bar, current, total)))b.WriteString("\n\n")// Segment infob.WriteString(fmt.Sprintf("Segment: %.1fs - %.1fs (%.1fs)\n",seg.StartTime, seg.EndTime, seg.EndTime-seg.StartTime))b.WriteString("\n")// Spectrogram placeholder (Kitty image renders separately)b.WriteString(" [spectrogram]\n")b.WriteString("\n")// LabelsfilterLabels := seg.GetFilterLabels(m.state.Config.Filter)if len(filterLabels) > 0 {b.WriteString(labelStyle.Render("Labels:"))b.WriteString("\n")for _, l := range filterLabels {b.WriteString(fmt.Sprintf(" • %s\n", tools.FormatLabels([]*utils.Label{l}, m.state.Config.Filter)))}}b.WriteString("\n")// Bindings helpb.WriteString(helpStyle.Render("Bindings: "))var bindings []stringfor _, b := range m.state.Config.Bindings {if b.CallType != "" {bindings = append(bindings, fmt.Sprintf("%s=%s/%s", b.Key, b.Species, b.CallType))} else {bindings = append(bindings, fmt.Sprintf("%s=%s", b.Key, b.Species))}}b.WriteString(helpStyle.Render(strings.Join(bindings, " ")))b.WriteString("\n")b.WriteString(helpStyle.Render("[q]uit [,]prev [.]next"))b.WriteString("\n\n")// Key bufferif m.keyBuffer != "" {b.WriteString(keyStyle.Render(fmt.Sprintf("> %s_", m.keyBuffer)))b.WriteString("\n")} else {b.WriteString("> _\n")}// Errorif m.err != "" {b.WriteString("\n")b.WriteString(errorStyle.Render(m.err))}return b.String()}
package toolsimport ("testing""skraak/utils")func TestParseKeyBuffer(t *testing.T) {bindings := []KeyBinding{{Key: "k", Species: "Kiwi"},{Key: "n", Species: "Don't Know"},{Key: "p", Species: "Morepork"},{Key: "d", CallType: "Duet"},{Key: "f", CallType: "Female"},}state := &ClassifyState{Config: ClassifyConfig{Bindings: bindings},}tests := []struct {buffer stringwant *BindingResultwantNil bool}{{"k", &BindingResult{Species: []string{"Kiwi"}}, false},{"n", &BindingResult{Species: []string{"Don't Know"}}, false},{"p", &BindingResult{Species: []string{"Morepork"}}, false},{"x", nil, true}, // unknown key{"kd", &BindingResult{Species: []string{"Kiwi"}, CallType: "Duet"}, false},{"kf", &BindingResult{Species: []string{"Kiwi"}, CallType: "Female"}, false},{"kp", &BindingResult{Species: []string{"Kiwi", "Morepork"}}, false}, // species+species{"dp", nil, true}, // calltype + species not allowed{"abc", nil, true}, // too long}for _, tt := range tests {got := state.ParseKeyBuffer(tt.buffer)if tt.wantNil {if got != nil {t.Errorf("ParseKeyBuffer(%q) = %v, want nil", tt.buffer, got)}} else {if got == nil {t.Errorf("ParseKeyBuffer(%q) = nil, want %+v", tt.buffer, tt.want)continue}if got.CallType != tt.want.CallType {t.Errorf("ParseKeyBuffer(%q).CallType = %q, want %q", tt.buffer, got.CallType, tt.want.CallType)}if len(got.Species) != len(tt.want.Species) {t.Errorf("ParseKeyBuffer(%q).Species = %v, want %v", tt.buffer, got.Species, tt.want.Species)}}}}func TestApplyBinding(t *testing.T) {bindings := []KeyBinding{{Key: "k", Species: "Kiwi"},{Key: "n", Species: "Don't Know"},{Key: "p", Species: "Morepork"},}df := &utils.DataFile{Meta: &utils.DataMeta{},Segments: []*utils.Segment{{StartTime: 10.0,EndTime: 20.0,Labels: []*utils.Label{{Species: "Unknown", Certainty: 50, Filter: "test-filter"},},},},}state := &ClassifyState{Config: ClassifyConfig{Filter: "test-filter",Reviewer: "David",Bindings: bindings,},DataFiles: []*utils.DataFile{df},FileIdx: 0,SegmentIdx: 0,}// Apply "k" = Kiwiresult := &BindingResult{Species: []string{"Kiwi"}}state.ApplyBinding(result)// Check label was updatedif len(df.Segments[0].Labels) != 1 {t.Errorf("expected 1 label, got %d", len(df.Segments[0].Labels))}if df.Segments[0].Labels[0].Species != "Kiwi" {t.Errorf("expected Species=Kiwi, got %s", df.Segments[0].Labels[0].Species)}if df.Segments[0].Labels[0].Certainty != 100 {t.Errorf("expected Certainty=100, got %d", df.Segments[0].Labels[0].Certainty)}if df.Meta.Reviewer != "David" {t.Errorf("expected Reviewer=David, got %s", df.Meta.Reviewer)}// Apply "n" = Don't Know (certainty should be 0)result = &BindingResult{Species: []string{"Don't Know"}}state.ApplyBinding(result)if df.Segments[0].Labels[0].Species != "Don't Know" {t.Errorf("expected Species=Don't Know, got %s", df.Segments[0].Labels[0].Species)}if df.Segments[0].Labels[0].Certainty != 0 {t.Errorf("expected Certainty=0 for Don't Know, got %d", df.Segments[0].Labels[0].Certainty)}}func TestApplyBindingSpeciesSpecies(t *testing.T) {bindings := []KeyBinding{{Key: "k", Species: "Kiwi"},{Key: "p", Species: "Morepork"},}df := &utils.DataFile{Meta: &utils.DataMeta{},Segments: []*utils.Segment{{StartTime: 10.0,EndTime: 20.0,Labels: []*utils.Label{{Species: "Unknown", Certainty: 50, Filter: "test-filter"},},},},}state := &ClassifyState{Config: ClassifyConfig{Filter: "test-filter",Reviewer: "David",Bindings: bindings,},DataFiles: []*utils.DataFile{df},FileIdx: 0,SegmentIdx: 0,}// Apply "kp" = Kiwi + Morepork (two labels)result := &BindingResult{Species: []string{"Kiwi", "Morepork"}}state.ApplyBinding(result)// Should have 2 labels nowif len(df.Segments[0].Labels) != 2 {t.Errorf("expected 2 labels, got %d", len(df.Segments[0].Labels))}// Check both species present (sorted alphabetically)species := make(map[string]bool)for _, l := range df.Segments[0].Labels {species[l.Species] = true}if !species["Kiwi"] || !species["Morepork"] {t.Errorf("expected Kiwi and Morepork, got %v", species)}}func TestApplyBindingWithCallType(t *testing.T) {bindings := []KeyBinding{{Key: "k", Species: "Kiwi"},{Key: "d", CallType: "Duet"},}df := &utils.DataFile{Meta: &utils.DataMeta{},Segments: []*utils.Segment{{StartTime: 10.0,EndTime: 20.0,Labels: []*utils.Label{{Species: "Unknown", Certainty: 50, Filter: "test-filter"},},},},}state := &ClassifyState{Config: ClassifyConfig{Filter: "test-filter",Reviewer: "David",Bindings: bindings,},DataFiles: []*utils.DataFile{df},FileIdx: 0,SegmentIdx: 0,}// Apply "kd" = Kiwi/Duetresult := &BindingResult{Species: []string{"Kiwi"}, CallType: "Duet"}state.ApplyBinding(result)if df.Segments[0].Labels[0].CallType != "Duet" {t.Errorf("expected CallType=Duet, got %s", df.Segments[0].Labels[0].CallType)}}
package toolsimport ("fmt""sort""strings""skraak/utils")// KeyBinding maps a key to a species/calltypetype KeyBinding struct {Key string // single char: "k", "n", "p"Species string // "Kiwi", "Don't Know", "Morepork"CallType string // "Duet", "Female", "Male" (optional)}// ClassifyConfig holds the configuration for classificationtype ClassifyConfig struct {Folder stringFile stringFilter stringReviewer stringKeyDelay float64Color boolBindings []KeyBinding}// ClassifyState holds the current state for TUItype ClassifyState struct {Config ClassifyConfigDataFiles []*utils.DataFileFileIdx intSegmentIdx intDirty bool}// BindingResult represents parsed key buffer resulttype BindingResult struct {Species []string // one or two speciesCallType string // optional calltype}// LoadDataFiles loads all .data files for classificationfunc LoadDataFiles(config ClassifyConfig) (*ClassifyState, error) {var filePaths []stringvar err errorif config.File != "" {filePaths = []string{config.File}} else {filePaths, err = utils.FindDataFiles(config.Folder)if err != nil {return nil, fmt.Errorf("find data files: %w", err)}}if len(filePaths) == 0 {return nil, fmt.Errorf("no .data files found")}// Parse all filesdataFiles := make([]*utils.DataFile, 0, len(filePaths))for _, path := range filePaths {df, err := utils.ParseDataFile(path)if err != nil {continue // skip invalid files}dataFiles = append(dataFiles, df)}if len(dataFiles) == 0 {return nil, fmt.Errorf("no valid .data files")}// Sort files by name (earliest to latest by filename timestamp)sort.Slice(dataFiles, func(i, j int) bool {return dataFiles[i].FilePath < dataFiles[j].FilePath})return &ClassifyState{Config: config,DataFiles: dataFiles,}, nil}// CurrentFile returns the current data filefunc (s *ClassifyState) CurrentFile() *utils.DataFile {if s.FileIdx >= len(s.DataFiles) {return nil}return s.DataFiles[s.FileIdx]}// CurrentSegment returns the current segmentfunc (s *ClassifyState) CurrentSegment() *utils.Segment {df := s.CurrentFile()if df == nil {return nil}// Get segments with matching filtersegments := s.getFilteredSegments(df)if s.SegmentIdx >= len(segments) {return nil}return segments[s.SegmentIdx]}// getFilteredSegments returns segments matching the filterfunc (s *ClassifyState) getFilteredSegments(df *utils.DataFile) []*utils.Segment {if s.Config.Filter == "" {return df.Segments}var filtered []*utils.Segmentfor _, seg := range df.Segments {if seg.HasFilterLabel(s.Config.Filter) {filtered = append(filtered, seg)}}return filtered}// TotalSegments returns total segments to reviewfunc (s *ClassifyState) TotalSegments() int {total := 0for _, df := range s.DataFiles {total += len(s.getFilteredSegments(df))}return total}// CurrentSegmentNumber returns 1-based segment numberfunc (s *ClassifyState) CurrentSegmentNumber() int {count := 0for i, df := range s.DataFiles {segs := s.getFilteredSegments(df)if i < s.FileIdx {count += len(segs)} else if i == s.FileIdx {count += s.SegmentIdx + 1} else {break}}return count}// NextSegment moves to the next segment, returns false if at endfunc (s *ClassifyState) NextSegment() bool {df := s.CurrentFile()if df == nil {return false}segs := s.getFilteredSegments(df)if s.SegmentIdx+1 < len(segs) {s.SegmentIdx++return true}// Move to next fileif s.FileIdx+1 < len(s.DataFiles) {s.FileIdx++s.SegmentIdx = 0return true}return false}// PrevSegment moves to the previous segment, returns false if at startfunc (s *ClassifyState) PrevSegment() bool {if s.SegmentIdx > 0 {s.SegmentIdx--return true}// Move to previous fileif s.FileIdx > 0 {s.FileIdx--df := s.CurrentFile()segs := s.getFilteredSegments(df)s.SegmentIdx = len(segs) - 1if s.SegmentIdx < 0 {s.SegmentIdx = 0}return true}return false}// ParseKeyBuffer parses accumulated keys into binding resultfunc (s *ClassifyState) ParseKeyBuffer(buffer string) *BindingResult {if len(buffer) == 0 || len(buffer) > 2 {return nil}// Single key bindingif len(buffer) == 1 {for _, b := range s.Config.Bindings {if b.Key == buffer {return &BindingResult{Species: []string{b.Species},CallType: b.CallType,}}}return nil}// Two keys: try species+calltype or species+specieskey1 := buffer[0:1]key2 := buffer[1:2]var binding1, binding2 *KeyBindingfor i := range s.Config.Bindings {if s.Config.Bindings[i].Key == key1 {binding1 = &s.Config.Bindings[i]}if s.Config.Bindings[i].Key == key2 {binding2 = &s.Config.Bindings[i]}}if binding1 == nil || binding2 == nil {return nil}// species + calltypeif binding2.CallType != "" {return &BindingResult{Species: []string{binding1.Species},CallType: binding2.CallType,}}// species + speciesif binding1.CallType == "" {return &BindingResult{Species: []string{binding1.Species, binding2.Species},}}return nil}// ApplyBinding applies a binding result to the current segmentfunc (s *ClassifyState) ApplyBinding(result *BindingResult) {seg := s.CurrentSegment()if seg == nil {return}df := s.CurrentFile()if df == nil {return}// Set reviewerdf.Meta.Reviewer = s.Config.Reviewer// Get labels matching filterfilterLabels := seg.GetFilterLabels(s.Config.Filter)// Determine certainty: 0 for Don't Know, 100 for otherscertainty := 100for _, sp := range result.Species {if sp == "Don't Know" {certainty = 0break}}if len(filterLabels) == 0 {// No matching labels, add new onesfor _, sp := range result.Species {seg.Labels = append(seg.Labels, &utils.Label{Species: sp,Certainty: certainty,Filter: s.Config.Filter,CallType: result.CallType,})}} else {// Edit first matching label, remove restfilterLabels[0].Species = result.Species[0]filterLabels[0].Certainty = certaintyif result.CallType != "" {filterLabels[0].CallType = result.CallType}// If two species, add second labelif len(result.Species) > 1 {seg.Labels = append(seg.Labels, &utils.Label{Species: result.Species[1],Certainty: certainty,Filter: s.Config.Filter,})}// Remove extra matching labelsif len(filterLabels) > 1 {// Build new labels listvar newLabels []*utils.Labelfor _, l := range seg.Labels {keep := truefor _, fl := range filterLabels[1:] {if l == fl {keep = falsebreak}}if keep {newLabels = append(newLabels, l)}}seg.Labels = newLabels}}// Re-sort labelssort.Slice(seg.Labels, func(i, j int) bool {return seg.Labels[i].Species < seg.Labels[j].Species})s.Dirty = true}// Save saves the current filefunc (s *ClassifyState) Save() error {df := s.CurrentFile()if df == nil {return nil}if !s.Dirty {return nil}err := df.Write(df.FilePath)if err != nil {return err}s.Dirty = falsereturn nil}// FormatLabels formats labels for displayfunc FormatLabels(labels []*utils.Label, filter string) string {var parts []stringfor _, l := range labels {if filter != "" && l.Filter != filter {continue}part := l.Speciesif l.CallType != "" {part += "/" + l.CallType}part += fmt.Sprintf(" (%d%%)", l.Certainty)if l.Filter != "" {part += " [" + l.Filter + "]"}parts = append(parts, part)}return strings.Join(parts, ", ")}
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirectgithub.com/charmbracelet/bubbletea v1.3.10 // indirectgithub.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirectgithub.com/charmbracelet/lipgloss v1.1.0 // indirectgithub.com/charmbracelet/x/ansi v0.10.1 // indirectgithub.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirectgithub.com/charmbracelet/x/term v0.2.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirectgithub.com/mattn/go-localereader v0.0.1 // indirectgithub.com/mattn/go-runewidth v0.0.16 // indirectgithub.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirectgithub.com/muesli/cancelreader v0.2.2 // indirectgithub.com/muesli/termenv v0.16.0 // indirect
package cmdimport ("fmt""os""strings"tea "github.com/charmbracelet/bubbletea""skraak/tools""skraak/tui")// RunCallsClassify handles the "calls classify" subcommandfunc RunCallsClassify(args []string) {var folder, file, filter, reviewer stringvar keyDelay float64 = 0.5var color boolvar bindings []tools.KeyBinding// Parse argumentsi := 0for i < len(args) {arg := args[i]switch arg {case "--folder":if i+1 >= len(args) {fmt.Fprintf(os.Stderr, "Error: --folder requires a value\n")os.Exit(1)}folder = args[i+1]i += 2case "--file":if i+1 >= len(args) {fmt.Fprintf(os.Stderr, "Error: --file requires a value\n")os.Exit(1)}file = args[i+1]i += 2case "--filter":if i+1 >= len(args) {fmt.Fprintf(os.Stderr, "Error: --filter requires a value\n")os.Exit(1)}filter = args[i+1]i += 2case "--reviewer":if i+1 >= len(args) {fmt.Fprintf(os.Stderr, "Error: --reviewer requires a value\n")os.Exit(1)}reviewer = args[i+1]i += 2case "--key-delay":if i+1 >= len(args) {fmt.Fprintf(os.Stderr, "Error: --key-delay requires a value\n")os.Exit(1)}fmt.Sscanf(args[i+1], "%f", &keyDelay)i += 2case "--color":color = truei++case "--bind":// Marker for bindings section, no valuei++case "--species":if i+1 >= len(args) {fmt.Fprintf(os.Stderr, "Error: --species requires a value\n")os.Exit(1)}// Parse key="Species" formatkey, species := parseBinding(args[i+1])bindings = append(bindings, tools.KeyBinding{Key: key, Species: species})i += 2case "--call_type":if i+1 >= len(args) {fmt.Fprintf(os.Stderr, "Error: --call_type requires a value\n")os.Exit(1)}// Parse key="CallType" formatkey, calltype := parseBinding(args[i+1])bindings = append(bindings, tools.KeyBinding{Key: key, CallType: calltype})i += 2default:fmt.Fprintf(os.Stderr, "Error: unknown flag: %s\n", arg)os.Exit(1)}}// Validate required flagsif folder == "" && file == "" {fmt.Fprintf(os.Stderr, "Error: --folder or --file is required\n")os.Exit(1)}if reviewer == "" {fmt.Fprintf(os.Stderr, "Error: --reviewer is required\n")os.Exit(1)}if len(bindings) == 0 {fmt.Fprintf(os.Stderr, "Error: at least one --species binding is required\n")os.Exit(1)}// Build configconfig := tools.ClassifyConfig{Folder: folder,File: file,Filter: filter,Reviewer: reviewer,KeyDelay: keyDelay,Color: color,Bindings: bindings,}// Load data filesstate, err := tools.LoadDataFiles(config)if err != nil {fmt.Fprintf(os.Stderr, "Error: %v\n", err)os.Exit(1)}fmt.Fprintf(os.Stderr, "Loaded %d .data files, %d segments\n",len(state.DataFiles), state.TotalSegments())// Launch TUIp := tea.NewProgram(tui.New(state))if _, err := p.Run(); err != nil {fmt.Fprintf(os.Stderr, "Error: %v\n", err)os.Exit(1)}}// parseBinding parses "k=Kiwi" or "d=Duet" formatfunc parseBinding(s string) (key, value string) {parts := strings.SplitN(s, "=", 2)if len(parts) != 2 {fmt.Fprintf(os.Stderr, "Error: invalid binding format: %s (expected key=value)\n", s)os.Exit(1)}return parts[0], parts[1]}