diff --git a/common/sync.go b/common/sync.go new file mode 100644 index 0000000..802fd16 --- /dev/null +++ b/common/sync.go @@ -0,0 +1,85 @@ +package common + +import ( + "log/slog" + + "tasksquire/taskwarrior" + "tasksquire/timewarrior" +) + +// FindTaskByUUID queries Taskwarrior for a task with the given UUID. +// Returns nil if not found. +func FindTaskByUUID(tw taskwarrior.TaskWarrior, uuid string) *taskwarrior.Task { + if uuid == "" { + return nil + } + + // Use empty report to query by UUID filter + report := &taskwarrior.Report{Name: ""} + tasks := tw.GetTasks(report, "uuid:"+uuid) + + if len(tasks) > 0 { + return tasks[0] + } + + return nil +} + +// SyncIntervalToTask synchronizes a Timewarrior interval's state to the corresponding Taskwarrior task. +// Action should be "start" or "stop". +// This function is idempotent and handles edge cases gracefully. +func SyncIntervalToTask(interval *timewarrior.Interval, tw taskwarrior.TaskWarrior, action string) { + if interval == nil { + return + } + + // Extract UUID from interval tags + uuid := timewarrior.ExtractUUID(interval.Tags) + if uuid == "" { + slog.Debug("Interval has no UUID tag, skipping task sync", + "intervalID", interval.ID) + return + } + + // Find corresponding task + task := FindTaskByUUID(tw, uuid) + if task == nil { + slog.Warn("Task not found for UUID, skipping sync", + "uuid", uuid) + return + } + + // Perform sync action + switch action { + case "start": + // Start task if it's pending (idempotent - taskwarrior handles already-started tasks) + if task.Status == "pending" { + slog.Info("Starting Taskwarrior task from interval", + "uuid", uuid, + "description", task.Description, + "alreadyStarted", task.Start != "") + tw.StartTask(task) + } else { + slog.Debug("Task not pending, skipping start", + "uuid", uuid, + "status", task.Status) + } + + case "stop": + // Only stop if task is pending and currently started + if task.Status == "pending" && task.Start != "" { + slog.Info("Stopping Taskwarrior task from interval", + "uuid", uuid, + "description", task.Description) + tw.StopTask(task) + } else { + slog.Debug("Task not started or not pending, skipping stop", + "uuid", uuid, + "status", task.Status, + "hasStart", task.Start != "") + } + + default: + slog.Error("Unknown sync action", "action", action) + } +} diff --git a/components/autocomplete/autocomplete.go b/components/autocomplete/autocomplete.go index 248e74a..6c1d6f8 100644 --- a/components/autocomplete/autocomplete.go +++ b/components/autocomplete/autocomplete.go @@ -89,6 +89,11 @@ func (a *Autocomplete) SetSuggestions(suggestions []string) { a.updateFilteredSuggestions() } +// HasSuggestions returns true if the autocomplete is currently showing suggestions +func (a *Autocomplete) HasSuggestions() bool { + return a.showSuggestions && len(a.filteredSuggestions) > 0 +} + // Init initializes the autocomplete func (a *Autocomplete) Init() tea.Cmd { return textinput.Blink diff --git a/components/picker/picker.go b/components/picker/picker.go index 2d47428..a28827f 100644 --- a/components/picker/picker.go +++ b/components/picker/picker.go @@ -36,6 +36,7 @@ type Picker struct { onCreate func(string) tea.Cmd title string filterByDefault bool + defaultValue string baseItems []list.Item focused bool } @@ -54,6 +55,12 @@ func WithOnCreate(onCreate func(string) tea.Cmd) PickerOption { } } +func WithDefaultValue(value string) PickerOption { + return func(p *Picker) { + p.defaultValue = value + } +} + func (p *Picker) Focus() tea.Cmd { p.focused = true return nil @@ -88,6 +95,7 @@ func New( l.SetShowHelp(false) l.SetShowStatusBar(false) l.SetFilteringEnabled(true) + l.Filter = list.UnsortedFilter // Preserve item order, don't rank by match quality // Custom key for filtering (insert mode) l.KeyMap.Filter = key.NewBinding( @@ -112,16 +120,24 @@ func New( opt(p) } - if p.filterByDefault { - // Manually trigger filter mode on the list so it doesn't require a global key press - var cmd tea.Cmd - p.list, cmd = p.list.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}}) - // We can ignore the command here as it's likely just for blinking, which will happen on Init anyway - _ = cmd + // If a default value is provided, don't start in filter mode + if p.defaultValue != "" { + p.filterByDefault = false } + if p.filterByDefault { + // Manually trigger filter mode on the list so it doesn't require a global key press + p.list, _ = p.list.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}}) + } + + // Refresh items after entering filter mode to ensure they're visible p.Refresh() + // If a default value is provided, select the corresponding item + if p.defaultValue != "" { + p.SelectItemByFilterValue(p.defaultValue) + } + return p } @@ -131,18 +147,22 @@ func (p *Picker) Refresh() tea.Cmd { } func (p *Picker) updateListItems() tea.Cmd { - items := p.baseItems - filterVal := p.list.FilterValue() + return p.updateListItemsWithFilter(p.list.FilterValue()) +} + +func (p *Picker) updateListItemsWithFilter(filterVal string) tea.Cmd { + items := make([]list.Item, 0, len(p.baseItems)+1) + + // First add all base items + items = append(items, p.baseItems...) if p.onCreate != nil && filterVal != "" { + // Add the creation item at the end (bottom of the list) newItem := creationItem{ text: "(new) " + filterVal, filter: filterVal, } - newItems := make([]list.Item, len(items)+1) - copy(newItems, items) - newItems[len(items)] = newItem - items = newItems + items = append(items, newItem) } return p.list.SetItems(items) @@ -162,7 +182,9 @@ func (p *Picker) SetSize(width, height int) { } func (p *Picker) Init() tea.Cmd { - return nil + // Trigger list item update to ensure items are properly displayed, + // especially when in filter mode with an empty filter + return p.updateListItems() } func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -171,17 +193,31 @@ func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } var cmd tea.Cmd + var cmds []tea.Cmd switch msg := msg.(type) { case tea.KeyMsg: - // If filtering, let the list handle keys (including Enter to stop filtering) + // If filtering, update items with predicted filter before list processes the key if p.list.FilterState() == list.Filtering { - // if key.Matches(msg, p.common.Keymap.Ok) { - // items := p.list.VisibleItems() - // if len(items) == 1 { - // return p, p.handleSelect(items[0]) - // } - // } + currentFilter := p.list.FilterValue() + predictedFilter := currentFilter + + // Predict what the filter will be after this key + switch msg.Type { + case tea.KeyRunes: + predictedFilter = currentFilter + string(msg.Runes) + case tea.KeyBackspace: + if len(currentFilter) > 0 { + predictedFilter = currentFilter[:len(currentFilter)-1] + } + } + + // Update items with predicted filter before list processes the message + if predictedFilter != currentFilter { + preCmd := p.updateListItemsWithFilter(predictedFilter) + cmds = append(cmds, preCmd) + } + break // Pass to list.Update } @@ -195,15 +231,10 @@ func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - prevFilter := p.list.FilterValue() p.list, cmd = p.list.Update(msg) + cmds = append(cmds, cmd) - if p.list.FilterValue() != prevFilter { - updateCmd := p.updateListItems() - return p, tea.Batch(cmd, updateCmd) - } - - return p, cmd + return p, tea.Batch(cmds...) } func (p *Picker) handleSelect(item list.Item) tea.Cmd { diff --git a/on-modify.timewarrior b/on-modify.timewarrior new file mode 100644 index 0000000..9a0f566 --- /dev/null +++ b/on-modify.timewarrior @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 + +############################################################################### +# +# Copyright 2016 - 2021, 2023, Gothenburg Bit Factory +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# https://www.opensource.org/licenses/mit-license.php +# +############################################################################### + +import json +import subprocess +import sys + +# Hook should extract all the following for use as Timewarrior tags: +# UUID +# Project +# Tags +# Description +# UDAs + +try: + input_stream = sys.stdin.buffer +except AttributeError: + input_stream = sys.stdin + + +def extract_tags_from(json_obj): + # Extract attributes for use as tags. + tags = [json_obj['description']] + + # Add UUID with prefix for reliable task linking + if 'uuid' in json_obj: + tags.append('uuid:' + json_obj['uuid']) + + # Add project with prefix for separate column display + if 'project' in json_obj: + tags.append('project:' + json_obj['project']) + + if 'tags' in json_obj: + if type(json_obj['tags']) is str: + # Usage of tasklib (e.g. in taskpirate) converts the tag list into a string + # If this is the case, convert it back into a list first + # See https://github.com/tbabej/taskpirate/issues/11 + task_tags = [tag for tag in json_obj['tags'].split(',') if tag != 'next'] + tags.extend(task_tags) + else: + # Filter out the 'next' tag + task_tags = [tag for tag in json_obj['tags'] if tag != 'next'] + tags.extend(task_tags) + + return tags + + +def extract_annotation_from(json_obj): + + if 'annotations' not in json_obj: + return '\'\'' + + return json_obj['annotations'][0]['description'] + + +def main(old, new): + + start_or_stop = '' + + # Started task. + if 'start' in new and 'start' not in old: + start_or_stop = 'start' + + # Stopped task. + elif ('start' not in new or 'end' in new) and 'start' in old: + start_or_stop = 'stop' + + if start_or_stop: + tags = extract_tags_from(new) + + subprocess.call(['timew', start_or_stop] + tags + [':yes']) + + # Modifications to task other than start/stop + elif 'start' in new and 'start' in old: + old_tags = extract_tags_from(old) + new_tags = extract_tags_from(new) + + if old_tags != new_tags: + subprocess.call(['timew', 'untag', '@1'] + old_tags + [':yes']) + subprocess.call(['timew', 'tag', '@1'] + new_tags + [':yes']) + + old_annotation = extract_annotation_from(old) + new_annotation = extract_annotation_from(new) + + if old_annotation != new_annotation: + subprocess.call(['timew', 'annotate', '@1', new_annotation]) + + +if __name__ == "__main__": + old = json.loads(input_stream.readline().decode("utf-8", errors="replace")) + new = json.loads(input_stream.readline().decode("utf-8", errors="replace")) + print(json.dumps(new)) + main(old, new) diff --git a/pages/main.go b/pages/main.go index fdb6e3b..dc4f637 100644 --- a/pages/main.go +++ b/pages/main.go @@ -12,8 +12,8 @@ type MainPage struct { common *common.Common activePage common.Component - taskPage common.Component - timePage common.Component + taskPage common.Component + timePage common.Component currentTab int width int height int diff --git a/pages/taskEditor.go b/pages/taskEditor.go index 23aafd9..19012ce 100644 --- a/pages/taskEditor.go +++ b/pages/taskEditor.go @@ -459,15 +459,21 @@ func NewTaskEdit(com *common.Common, task *taskwarrior.Task, isNew bool) *taskEd onSelect := func(item list.Item) tea.Cmd { return nil } + onCreate := func(newProject string) tea.Cmd { + // The new project name will be used as the project value + // when the task is saved + return nil + } - opts := []picker.PickerOption{} + opts := []picker.PickerOption{picker.WithOnCreate(onCreate)} if isNew { opts = append(opts, picker.WithFilterByDefault(true)) + } else { + opts = append(opts, picker.WithDefaultValue(task.Project)) } projPicker := picker.New(com, "Project", itemProvider, onSelect, opts...) projPicker.SetSize(70, 8) - projPicker.SelectItemByFilterValue(task.Project) projPicker.Blur() defaultKeymap := huh.NewDefaultKeyMap() diff --git a/pages/timeEditor.go b/pages/timeEditor.go index 03f90ae..4c9ef8f 100644 --- a/pages/timeEditor.go +++ b/pages/timeEditor.go @@ -40,9 +40,6 @@ func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *Time // Extract project from tags if it exists projects := com.TW.GetProjects() selectedProject, remainingTags := extractProjectFromTags(interval.Tags, projects) - if selectedProject == "" && len(projects) > 0 { - selectedProject = projects[0] // Default to first project (required) - } // Create project picker with onCreate support for new projects projectItemProvider := func() []list.Item { @@ -66,18 +63,23 @@ func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *Time } } + opts := []picker.PickerOption{ + picker.WithOnCreate(projectOnCreate), + } + if selectedProject != "" { + opts = append(opts, picker.WithDefaultValue(selectedProject)) + } else { + opts = append(opts, picker.WithFilterByDefault(true)) + } + projectPicker := picker.New( com, "Project", projectItemProvider, projectOnSelect, - picker.WithOnCreate(projectOnCreate), - picker.WithFilterByDefault(true), + opts..., ) projectPicker.SetSize(50, 10) // Compact size for inline use - if selectedProject != "" { - projectPicker.SelectItemByFilterValue(selectedProject) - } // Create start timestamp editor startEditor := timestampeditor.New(com). @@ -129,8 +131,14 @@ func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case timeEditorProjectSelectedMsg: // Update selected project p.selectedProject = msg.project + // Blur current field (project picker) + p.blurCurrentField() + // Advance to tags field + p.currentField = 1 // Refresh tag autocomplete with filtered combinations cmds = append(cmds, p.updateTagSuggestions()) + // Focus tags input + cmds = append(cmds, p.focusCurrentField()) return p, tea.Batch(cmds...) case tea.KeyMsg: @@ -144,18 +152,33 @@ func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return model, BackCmd case key.Matches(msg, p.common.Keymap.Ok): - // Don't save if the project picker is focused - let it handle Enter - if p.currentField != 0 { - // Save and exit - p.saveInterval() - model, err := p.common.PopPage() - if err != nil { - slog.Error("page stack empty") - return nil, tea.Quit - } - return model, tea.Batch(tea.Batch(cmds...), refreshIntervals) + // Handle Enter based on current field + if p.currentField == 0 { + // Project picker - let it handle Enter (will trigger projectSelectedMsg) + break } - // If picker is focused, let it handle the key below + + if p.currentField == 1 { + // Tags field + if p.tagsInput.HasSuggestions() { + // Let autocomplete handle suggestion selection + break + } + // Tags confirmed without suggestions - advance to start timestamp + p.blurCurrentField() + p.currentField = 2 + cmds = append(cmds, p.focusCurrentField()) + return p, tea.Batch(cmds...) + } + + // For all other fields (2-4: start, end, adjust), save and exit + p.saveInterval() + model, err := p.common.PopPage() + if err != nil { + slog.Error("page stack empty") + return nil, tea.Quit + } + return model, tea.Batch(tea.Batch(cmds...), refreshIntervals) case key.Matches(msg, p.common.Keymap.Next): // Move to next field @@ -347,15 +370,16 @@ func (p *TimeEditorPage) saveInterval() { // Add project to tags if not already present if p.selectedProject != "" { + projectTag := "project:" + p.selectedProject projectExists := false for _, tag := range tags { - if tag == p.selectedProject { + if tag == projectTag { projectExists = true break } } if !projectExists { - tags = append([]string{p.selectedProject}, tags...) // Prepend project + tags = append([]string{projectTag}, tags...) // Prepend project tag } } @@ -415,11 +439,15 @@ func extractProjectFromTags(tags []string, projects []string) (string, []string) var remaining []string for _, tag := range tags { - if foundProject == "" && projectSet[tag] { - foundProject = tag // First matching project - } else { - remaining = append(remaining, tag) + // Check if this tag is a project tag (format: "project:projectname") + if strings.HasPrefix(tag, "project:") { + projectName := strings.TrimPrefix(tag, "project:") + if foundProject == "" && projectSet[projectName] { + foundProject = projectName // First matching project + continue // Don't add to remaining tags + } } + remaining = append(remaining, tag) } return foundProject, remaining @@ -432,6 +460,8 @@ func filterTagCombinationsByProject(combinations []string, project string) []str return combinations } + projectTag := "project:" + project + var filtered []string for _, combo := range combinations { // Parse the combination into individual tags @@ -439,11 +469,11 @@ func filterTagCombinationsByProject(combinations []string, project string) []str // Check if project exists in this combination for _, tag := range tags { - if tag == project { + if tag == projectTag { // Found the project - now remove it from display var displayTags []string for _, t := range tags { - if t != project { + if t != projectTag { displayTags = append(displayTags, t) } } diff --git a/pages/timePage.go b/pages/timePage.go index 7085462..70451d2 100644 --- a/pages/timePage.go +++ b/pages/timePage.go @@ -21,6 +21,7 @@ type TimePage struct { data timewarrior.Intervals shouldSelectActive bool + pendingSyncAction string // "start", "stop", or "" (empty means no pending action) selectedTimespan string subpage common.Component @@ -163,8 +164,20 @@ func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case intervalsMsg: p.data = timewarrior.Intervals(msg) p.populateTable(p.data) + + // If we have a pending sync action (from continuing an interval), + // execute it now that intervals are refreshed + if p.pendingSyncAction != "" { + action := p.pendingSyncAction + p.pendingSyncAction = "" + cmds = append(cmds, p.syncActiveIntervalAfterRefresh(action)) + } case RefreshIntervalsMsg: cmds = append(cmds, p.getIntervals()) + cmds = append(cmds, doTick()) + case BackMsg: + // Restart tick loop when returning from subpage + cmds = append(cmds, doTick()) case tickMsg: cmds = append(cmds, p.getIntervals()) cmds = append(cmds, doTick()) @@ -182,13 +195,34 @@ func (p *TimePage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { row := p.intervals.SelectedRow() if row != nil { interval := (*timewarrior.Interval)(row) - if interval.IsActive() { - p.common.TimeW.StopTracking() - } else { - p.common.TimeW.ContinueInterval(interval.ID) - p.shouldSelectActive = true + + // Validate interval before proceeding + if interval.IsGap { + slog.Debug("Cannot start/stop gap interval") + return p, nil } - return p, tea.Batch(p.getIntervals(), doTick()) + + if interval.IsActive() { + // Stop tracking + p.common.TimeW.StopTracking() + // Sync: stop corresponding Taskwarrior task immediately (interval has UUID) + common.SyncIntervalToTask(interval, p.common.TW, "stop") + } else { + // Continue tracking - creates a NEW interval + slog.Info("Continuing interval for task sync", + "intervalID", interval.ID, + "hasUUID", timewarrior.ExtractUUID(interval.Tags) != "", + "uuid", timewarrior.ExtractUUID(interval.Tags)) + p.common.TimeW.ContinueInterval(interval.ID) + common.SyncIntervalToTask(interval, p.common.TW, "start") + p.shouldSelectActive = true + // Set pending sync action instead of syncing immediately + // This ensures we sync AFTER intervals are refreshed + p.pendingSyncAction = "start" + } + cmds = append(cmds, p.getIntervals()) + cmds = append(cmds, doTick()) + return p, tea.Batch(cmds...) } case key.Matches(msg, p.common.Keymap.Delete): row := p.intervals.SelectedRow() @@ -351,7 +385,8 @@ func (p *TimePage) populateTable(intervals timewarrior.Intervals) { {Title: "Start", Name: startField, Width: startEndWidth}, {Title: "End", Name: endField, Width: startEndWidth}, {Title: "Duration", Name: "duration", Width: 10}, - {Title: "Tags", Name: "tags", Width: 0}, // flexible width + {Title: "Project", Name: "project", Width: 0}, // flexible width + {Title: "Tags", Name: "tags", Width: 0}, // flexible width } // Calculate table height: total height - header (1 line) - blank line (1) - safety (1) @@ -419,3 +454,34 @@ func (p *TimePage) getIntervals() tea.Cmd { return intervalsMsg(intervals) } } + +// syncActiveInterval creates a command that syncs the currently active interval to Taskwarrior +func (p *TimePage) syncActiveInterval(action string) tea.Cmd { + return func() tea.Msg { + // Get the currently active interval + activeInterval := p.common.TimeW.GetActive() + if activeInterval != nil { + common.SyncIntervalToTask(activeInterval, p.common.TW, action) + } + return nil + } +} + +// syncActiveIntervalAfterRefresh is called AFTER intervals have been refreshed +// to ensure we're working with current data +func (p *TimePage) syncActiveIntervalAfterRefresh(action string) tea.Cmd { + return func() tea.Msg { + // At this point, intervals have been refreshed, so GetActive() will work + activeInterval := p.common.TimeW.GetActive() + if activeInterval != nil { + slog.Info("Syncing active interval to task after refresh", + "action", action, + "intervalID", activeInterval.ID, + "hasUUID", timewarrior.ExtractUUID(activeInterval.Tags) != "") + common.SyncIntervalToTask(activeInterval, p.common.TW, action) + } else { + slog.Warn("No active interval found after refresh, cannot sync to task") + } + return nil + } +} diff --git a/test/taskchampion.sqlite3 b/test/taskchampion.sqlite3 index bd4b6e8..1d8609d 100644 Binary files a/test/taskchampion.sqlite3 and b/test/taskchampion.sqlite3 differ diff --git a/timewarrior/models.go b/timewarrior/models.go index d9b4298..91e0e6c 100644 --- a/timewarrior/models.go +++ b/timewarrior/models.go @@ -83,7 +83,16 @@ func (i *Interval) GetString(field string) string { if len(i.Tags) == 0 { return "" } - return strings.Join(i.Tags, " ") + // Extract and filter special tags (uuid:, project:) + _, _, displayTags := ExtractSpecialTags(i.Tags) + return strings.Join(displayTags, " ") + + case "project": + project := ExtractProject(i.Tags) + if project == "" { + return "(none)" + } + return project case "duration": return i.GetDuration() diff --git a/timewarrior/tags.go b/timewarrior/tags.go new file mode 100644 index 0000000..0250981 --- /dev/null +++ b/timewarrior/tags.go @@ -0,0 +1,51 @@ +package timewarrior + +import ( + "strings" +) + +// Special tag prefixes for metadata +const ( + UUIDPrefix = "uuid:" + ProjectPrefix = "project:" +) + +// ExtractSpecialTags parses tags and separates special prefixed tags from display tags. +// Returns: uuid, project, and remaining display tags (description + user tags) +func ExtractSpecialTags(tags []string) (uuid string, project string, displayTags []string) { + displayTags = make([]string, 0, len(tags)) + + for _, tag := range tags { + switch { + case strings.HasPrefix(tag, UUIDPrefix): + uuid = strings.TrimPrefix(tag, UUIDPrefix) + case strings.HasPrefix(tag, ProjectPrefix): + project = strings.TrimPrefix(tag, ProjectPrefix) + default: + // Regular tag (description or user tag) + displayTags = append(displayTags, tag) + } + } + + return uuid, project, displayTags +} + +// ExtractUUID extracts just the UUID from tags (for sync operations) +func ExtractUUID(tags []string) string { + for _, tag := range tags { + if strings.HasPrefix(tag, UUIDPrefix) { + return strings.TrimPrefix(tag, UUIDPrefix) + } + } + return "" +} + +// ExtractProject extracts just the project name from tags +func ExtractProject(tags []string) string { + for _, tag := range tags { + if strings.HasPrefix(tag, ProjectPrefix) { + return strings.TrimPrefix(tag, ProjectPrefix) + } + } + return "" +} diff --git a/timewarrior/timewarrior.go b/timewarrior/timewarrior.go index fcb5990..ab7b4c4 100644 --- a/timewarrior/timewarrior.go +++ b/timewarrior/timewarrior.go @@ -142,10 +142,9 @@ func formatTagsForCombination(tags []string) string { return strings.Join(formatted, " ") } -func (ts *TimeSquire) GetIntervals(filter ...string) Intervals { - ts.mutex.Lock() - defer ts.mutex.Unlock() - +// getIntervalsUnlocked fetches intervals without acquiring mutex (for internal use only). +// Caller must hold ts.mutex. +func (ts *TimeSquire) getIntervalsUnlocked(filter ...string) Intervals { args := append(ts.defaultArgs, "export") if filter != nil { args = append(args, filter...) @@ -176,6 +175,14 @@ func (ts *TimeSquire) GetIntervals(filter ...string) Intervals { return intervals } +// GetIntervals fetches intervals with the given filter, acquiring mutex lock. +func (ts *TimeSquire) GetIntervals(filter ...string) Intervals { + ts.mutex.Lock() + defer ts.mutex.Unlock() + + return ts.getIntervalsUnlocked(filter...) +} + func (ts *TimeSquire) StartTracking(tags []string) error { ts.mutex.Lock() defer ts.mutex.Unlock() @@ -347,8 +354,8 @@ func (ts *TimeSquire) GetActive() *Interval { return nil } - // Get the active interval - intervals := ts.GetIntervals() + // Get the active interval using unlocked version (we already hold the mutex) + intervals := ts.getIntervalsUnlocked() for _, interval := range intervals { if interval.End == "" { return interval