Tear down everything
Fix config
This commit is contained in:
71
internal/common/common.go
Normal file
71
internal/common/common.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"tasksquire/internal/taskwarrior"
|
||||
"tasksquire/internal/timewarrior"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
type Common struct {
|
||||
Ctx context.Context
|
||||
TW taskwarrior.TaskWarrior
|
||||
TimeW timewarrior.TimeWarrior
|
||||
Keymap *Keymap
|
||||
Styles *Styles
|
||||
Udas []taskwarrior.Uda
|
||||
|
||||
pageStack *Stack[Component]
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func NewCommon(ctx context.Context, tw taskwarrior.TaskWarrior, timeW timewarrior.TimeWarrior) *Common {
|
||||
return &Common{
|
||||
Ctx: ctx,
|
||||
TW: tw,
|
||||
TimeW: timeW,
|
||||
Keymap: NewKeymap(),
|
||||
Styles: NewStyles(tw.GetConfig()),
|
||||
Udas: tw.GetUdas(),
|
||||
|
||||
pageStack: NewStack[Component](),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Common) SetSize(width, height int) {
|
||||
c.width = width
|
||||
c.height = height
|
||||
physicalWidth, physicalHeight, _ := term.GetSize(int(os.Stdout.Fd()))
|
||||
slog.Info("SetSize", "width", width, "height", height, "physicalWidth", physicalWidth, "physicalHeight", physicalHeight)
|
||||
}
|
||||
|
||||
func (c *Common) Width() int {
|
||||
return c.width
|
||||
}
|
||||
|
||||
func (c *Common) Height() int {
|
||||
return c.height
|
||||
}
|
||||
|
||||
func (c *Common) PushPage(page Component) {
|
||||
c.pageStack.Push(page)
|
||||
}
|
||||
|
||||
func (c *Common) PopPage() (Component, error) {
|
||||
component, err := c.pageStack.Pop()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
component.SetSize(c.width, c.height)
|
||||
return component, nil
|
||||
}
|
||||
|
||||
func (c *Common) HasSubpages() bool {
|
||||
return !c.pageStack.IsEmpty()
|
||||
}
|
||||
9
internal/common/component.go
Normal file
9
internal/common/component.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package common
|
||||
|
||||
import tea "charm.land/bubbletea/v2"
|
||||
|
||||
type Component interface {
|
||||
tea.Model
|
||||
//help.KeyMap
|
||||
SetSize(width int, height int)
|
||||
}
|
||||
183
internal/common/keymap.go
Normal file
183
internal/common/keymap.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/key"
|
||||
)
|
||||
|
||||
// Keymap is a collection of key bindings.
|
||||
type Keymap struct {
|
||||
Quit key.Binding
|
||||
Back key.Binding
|
||||
Ok key.Binding
|
||||
Delete key.Binding
|
||||
Input key.Binding
|
||||
Add key.Binding
|
||||
Edit key.Binding
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
Left key.Binding
|
||||
Right key.Binding
|
||||
Next key.Binding
|
||||
Prev key.Binding
|
||||
NextPage key.Binding
|
||||
PrevPage key.Binding
|
||||
SetReport key.Binding
|
||||
SetContext key.Binding
|
||||
SetProject key.Binding
|
||||
PickProjectTask key.Binding
|
||||
Select key.Binding
|
||||
Insert key.Binding
|
||||
Tag key.Binding
|
||||
Undo key.Binding
|
||||
Fill key.Binding
|
||||
StartStop key.Binding
|
||||
Join key.Binding
|
||||
ViewDetails key.Binding
|
||||
Subtask key.Binding
|
||||
}
|
||||
|
||||
// TODO: use config values for key bindings
|
||||
// NewKeymap creates a new Keymap.
|
||||
func NewKeymap() *Keymap {
|
||||
return &Keymap{
|
||||
Quit: key.NewBinding(
|
||||
key.WithKeys("q", "ctrl+c"),
|
||||
key.WithHelp("q, ctrl+c", "Quit"),
|
||||
),
|
||||
|
||||
Back: key.NewBinding(
|
||||
key.WithKeys("esc"),
|
||||
key.WithHelp("esc", "Back"),
|
||||
),
|
||||
|
||||
Ok: key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("enter", "Ok"),
|
||||
),
|
||||
|
||||
Delete: key.NewBinding(
|
||||
key.WithKeys("d"),
|
||||
key.WithHelp("d", "Delete"),
|
||||
),
|
||||
|
||||
Input: key.NewBinding(
|
||||
key.WithKeys(":"),
|
||||
key.WithHelp(":", "Input"),
|
||||
),
|
||||
|
||||
Add: key.NewBinding(
|
||||
key.WithKeys("a"),
|
||||
key.WithHelp("a", "Add new task"),
|
||||
),
|
||||
|
||||
Edit: key.NewBinding(
|
||||
key.WithKeys("e"),
|
||||
key.WithHelp("e", "Edit task"),
|
||||
),
|
||||
|
||||
Up: key.NewBinding(
|
||||
key.WithKeys("k", "up"),
|
||||
key.WithHelp("↑/k", "Up"),
|
||||
),
|
||||
|
||||
Down: key.NewBinding(
|
||||
key.WithKeys("j", "down"),
|
||||
key.WithHelp("↓/j", "Down"),
|
||||
),
|
||||
|
||||
Left: key.NewBinding(
|
||||
key.WithKeys("h", "left"),
|
||||
key.WithHelp("←/h", "Left"),
|
||||
),
|
||||
|
||||
Right: key.NewBinding(
|
||||
key.WithKeys("l", "right"),
|
||||
key.WithHelp("→/l", "Right"),
|
||||
),
|
||||
|
||||
Next: key.NewBinding(
|
||||
key.WithKeys("tab"),
|
||||
key.WithHelp("tab", "Next"),
|
||||
),
|
||||
|
||||
Prev: key.NewBinding(
|
||||
key.WithKeys("shift+tab"),
|
||||
key.WithHelp("shift+tab", "Previous"),
|
||||
),
|
||||
|
||||
NextPage: key.NewBinding(
|
||||
key.WithKeys("]", "L"),
|
||||
key.WithHelp("]/L", "Next page"),
|
||||
),
|
||||
|
||||
PrevPage: key.NewBinding(
|
||||
key.WithKeys("[", "H"),
|
||||
key.WithHelp("[/H", "Previous page"),
|
||||
),
|
||||
|
||||
SetReport: key.NewBinding(
|
||||
key.WithKeys("r"),
|
||||
key.WithHelp("r", "Set report"),
|
||||
),
|
||||
|
||||
SetContext: key.NewBinding(
|
||||
key.WithKeys("c"),
|
||||
key.WithHelp("c", "Set context"),
|
||||
),
|
||||
|
||||
SetProject: key.NewBinding(
|
||||
key.WithKeys("p"),
|
||||
key.WithHelp("p", "Set project"),
|
||||
),
|
||||
|
||||
PickProjectTask: key.NewBinding(
|
||||
key.WithKeys("P"),
|
||||
key.WithHelp("P", "Pick project task"),
|
||||
),
|
||||
|
||||
Select: key.NewBinding(
|
||||
key.WithKeys(" "),
|
||||
key.WithHelp("space", "Select"),
|
||||
),
|
||||
|
||||
Insert: key.NewBinding(
|
||||
key.WithKeys("i"),
|
||||
key.WithHelp("insert", "Insert mode"),
|
||||
),
|
||||
|
||||
Tag: key.NewBinding(
|
||||
key.WithKeys("t"),
|
||||
key.WithHelp("tag", "Tag"),
|
||||
),
|
||||
|
||||
Undo: key.NewBinding(
|
||||
key.WithKeys("u"),
|
||||
key.WithHelp("undo", "Undo"),
|
||||
),
|
||||
|
||||
Fill: key.NewBinding(
|
||||
key.WithKeys("f"),
|
||||
key.WithHelp("fill", "Fill gaps"),
|
||||
),
|
||||
|
||||
StartStop: key.NewBinding(
|
||||
key.WithKeys("s"),
|
||||
key.WithHelp("start/stop", "Start/Stop"),
|
||||
),
|
||||
|
||||
Join: key.NewBinding(
|
||||
key.WithKeys("J"),
|
||||
key.WithHelp("J", "Join with previous"),
|
||||
),
|
||||
|
||||
ViewDetails: key.NewBinding(
|
||||
key.WithKeys("v"),
|
||||
key.WithHelp("v", "view details"),
|
||||
),
|
||||
|
||||
Subtask: key.NewBinding(
|
||||
key.WithKeys("S"),
|
||||
key.WithHelp("S", "Create subtask"),
|
||||
),
|
||||
}
|
||||
}
|
||||
47
internal/common/stack.go
Normal file
47
internal/common/stack.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Stack[T any] struct {
|
||||
items []T
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
func NewStack[T any]() *Stack[T] {
|
||||
return &Stack[T]{
|
||||
items: make([]T, 0),
|
||||
mutex: sync.Mutex{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Stack[T]) Push(item T) {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
s.items = append(s.items, item)
|
||||
}
|
||||
|
||||
func (s *Stack[T]) Pop() (T, error) {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
if len(s.items) == 0 {
|
||||
var empty T
|
||||
return empty, errors.New("stack is empty")
|
||||
}
|
||||
|
||||
item := s.items[len(s.items)-1]
|
||||
s.items = s.items[:len(s.items)-1]
|
||||
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *Stack[T]) IsEmpty() bool {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
return len(s.items) == 0
|
||||
}
|
||||
234
internal/common/styles.go
Normal file
234
internal/common/styles.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"image/color"
|
||||
|
||||
"tasksquire/internal/taskwarrior"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
type TableStyle struct {
|
||||
Header lipgloss.Style
|
||||
Cell lipgloss.Style
|
||||
Selected lipgloss.Style
|
||||
}
|
||||
|
||||
type Palette struct {
|
||||
Primary lipgloss.Style
|
||||
Secondary lipgloss.Style
|
||||
Accent lipgloss.Style
|
||||
Muted lipgloss.Style
|
||||
Border lipgloss.Style
|
||||
Background lipgloss.Style
|
||||
Text lipgloss.Style
|
||||
}
|
||||
|
||||
type Styles struct {
|
||||
Colors map[string]*lipgloss.Style
|
||||
Palette Palette
|
||||
|
||||
Base lipgloss.Style
|
||||
|
||||
// Form *huh.Theme
|
||||
TableStyle TableStyle
|
||||
|
||||
Tab lipgloss.Style
|
||||
ActiveTab lipgloss.Style
|
||||
TabBar lipgloss.Style
|
||||
|
||||
ColumnFocused lipgloss.Style
|
||||
ColumnBlurred lipgloss.Style
|
||||
ColumnInsert lipgloss.Style
|
||||
}
|
||||
|
||||
func NewStyles(config *taskwarrior.TWConfig) *Styles {
|
||||
styles := Styles{}
|
||||
|
||||
colors := make(map[string]*lipgloss.Style)
|
||||
|
||||
for key, value := range config.GetConfig() {
|
||||
if strings.HasPrefix(key, "color.") {
|
||||
_, color, _ := strings.Cut(key, ".")
|
||||
colors[color] = parseColorString(value)
|
||||
}
|
||||
}
|
||||
|
||||
styles.Colors = colors
|
||||
|
||||
// Initialize Palette (Iceberg Light)
|
||||
styles.Palette.Primary = lipgloss.NewStyle().Foreground(lipgloss.Color("#2d539e")) // Blue
|
||||
styles.Palette.Secondary = lipgloss.NewStyle().Foreground(lipgloss.Color("#7759b4")) // Purple
|
||||
styles.Palette.Accent = lipgloss.NewStyle().Foreground(lipgloss.Color("#c57339")) // Orange
|
||||
styles.Palette.Muted = lipgloss.NewStyle().Foreground(lipgloss.Color("#8389a3")) // Grey
|
||||
styles.Palette.Border = lipgloss.NewStyle().Foreground(lipgloss.Color("#cad0de")) // Light Grey Border
|
||||
styles.Palette.Background = lipgloss.NewStyle().Background(lipgloss.Color("#e8e9ec")) // Light Background
|
||||
styles.Palette.Text = lipgloss.NewStyle().Foreground(lipgloss.Color("#33374c")) // Dark Text
|
||||
|
||||
// Override from config if available (example mapping)
|
||||
if s, ok := styles.Colors["primary"]; ok {
|
||||
styles.Palette.Primary = *s
|
||||
}
|
||||
if s, ok := styles.Colors["secondary"]; ok {
|
||||
styles.Palette.Secondary = *s
|
||||
}
|
||||
if s, ok := styles.Colors["active"]; ok {
|
||||
styles.Palette.Accent = *s
|
||||
}
|
||||
|
||||
styles.Base = lipgloss.NewStyle().Foreground(styles.Palette.Text.GetForeground())
|
||||
|
||||
styles.TableStyle = TableStyle{
|
||||
// Header: lipgloss.NewStyle().Bold(true).Padding(0, 1).BorderBottom(true),
|
||||
Cell: lipgloss.NewStyle().Padding(0, 1, 0, 0),
|
||||
Header: lipgloss.NewStyle().Bold(true).Padding(0, 1, 0, 0).Underline(true).Foreground(styles.Palette.Primary.GetForeground()),
|
||||
Selected: lipgloss.NewStyle().Bold(true).Reverse(true).Foreground(styles.Palette.Accent.GetForeground()),
|
||||
}
|
||||
|
||||
// formTheme := huh.ThemeBase()
|
||||
// formTheme.Focused.Title = formTheme.Focused.Title.Bold(true).Foreground(styles.Palette.Primary.GetForeground())
|
||||
// formTheme.Focused.SelectSelector = formTheme.Focused.SelectSelector.SetString("→ ").Foreground(styles.Palette.Accent.GetForeground())
|
||||
// formTheme.Focused.SelectedOption = formTheme.Focused.SelectedOption.Bold(true).Foreground(styles.Palette.Accent.GetForeground())
|
||||
// formTheme.Focused.MultiSelectSelector = formTheme.Focused.MultiSelectSelector.SetString("→ ").Foreground(styles.Palette.Accent.GetForeground())
|
||||
// formTheme.Focused.SelectedPrefix = formTheme.Focused.SelectedPrefix.SetString("✓ ").Foreground(styles.Palette.Accent.GetForeground())
|
||||
// formTheme.Focused.UnselectedPrefix = formTheme.Focused.SelectedPrefix.SetString("• ")
|
||||
// formTheme.Blurred.Title = formTheme.Blurred.Title.Bold(true).Foreground(styles.Palette.Muted.GetForeground())
|
||||
// formTheme.Blurred.SelectSelector = formTheme.Blurred.SelectSelector.SetString(" ")
|
||||
// formTheme.Blurred.SelectedOption = formTheme.Blurred.SelectedOption.Bold(true)
|
||||
// formTheme.Blurred.MultiSelectSelector = formTheme.Blurred.MultiSelectSelector.SetString(" ")
|
||||
// formTheme.Blurred.SelectedPrefix = formTheme.Blurred.SelectedPrefix.SetString("✓ ")
|
||||
// formTheme.Blurred.UnselectedPrefix = formTheme.Blurred.SelectedPrefix.SetString("• ")
|
||||
|
||||
// styles.Form = formTheme
|
||||
|
||||
styles.Tab = lipgloss.NewStyle().
|
||||
Padding(0, 1).
|
||||
Foreground(styles.Palette.Muted.GetForeground())
|
||||
|
||||
styles.ActiveTab = styles.Tab.
|
||||
Foreground(styles.Palette.Primary.GetForeground()).
|
||||
Bold(true)
|
||||
|
||||
styles.TabBar = lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder(), false, false, true, false).
|
||||
BorderForeground(styles.Palette.Border.GetForeground()).
|
||||
MarginBottom(1)
|
||||
|
||||
styles.ColumnFocused = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1).BorderForeground(styles.Palette.Primary.GetForeground())
|
||||
styles.ColumnBlurred = lipgloss.NewStyle().Border(lipgloss.HiddenBorder(), true).Padding(1).BorderForeground(styles.Palette.Border.GetForeground())
|
||||
styles.ColumnInsert = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1).BorderForeground(styles.Palette.Accent.GetForeground())
|
||||
|
||||
return &styles
|
||||
}
|
||||
|
||||
func (s *Styles) GetModalSize(width, height int) (int, int) {
|
||||
modalWidth := 60
|
||||
if width < 64 {
|
||||
modalWidth = width - 4
|
||||
}
|
||||
|
||||
modalHeight := 20
|
||||
if height < 24 {
|
||||
modalHeight = height - 4
|
||||
}
|
||||
|
||||
return modalWidth, modalHeight
|
||||
}
|
||||
|
||||
func parseColorString(color string) *lipgloss.Style {
|
||||
if color == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
style := lipgloss.NewStyle()
|
||||
|
||||
if strings.Contains(color, "on") {
|
||||
fgbg := strings.Split(color, "on")
|
||||
fg := strings.TrimSpace(fgbg[0])
|
||||
bg := strings.TrimSpace(fgbg[1])
|
||||
if fg != "" {
|
||||
style = style.Foreground(parseColor(fg))
|
||||
}
|
||||
if bg != "" {
|
||||
style = style.Background(parseColor(bg))
|
||||
}
|
||||
} else {
|
||||
style = style.Foreground(parseColor(strings.TrimSpace(color)))
|
||||
}
|
||||
|
||||
return &style
|
||||
}
|
||||
|
||||
func parseColor(color string) color.Color {
|
||||
if strings.HasPrefix(color, "rgb") {
|
||||
return lipgloss.Color(convertRgbToAnsi(strings.TrimPrefix(color, "rgb")))
|
||||
}
|
||||
if strings.HasPrefix(color, "color") {
|
||||
return lipgloss.Color(strings.TrimPrefix(color, "color"))
|
||||
}
|
||||
if strings.HasPrefix(color, "gray") {
|
||||
gray, err := strconv.Atoi(strings.TrimPrefix(color, "gray"))
|
||||
if err != nil {
|
||||
slog.Error("Invalid gray color format")
|
||||
return lipgloss.Color("0")
|
||||
}
|
||||
return lipgloss.Color(strconv.Itoa(gray + 232))
|
||||
}
|
||||
if ansi, okcolor := colorStrings[color]; okcolor {
|
||||
return lipgloss.Color(strconv.Itoa(ansi))
|
||||
}
|
||||
|
||||
slog.Error("Invalid color format")
|
||||
return lipgloss.Color("0")
|
||||
}
|
||||
|
||||
func convertRgbToAnsi(rgbString string) string {
|
||||
var err error
|
||||
|
||||
if len(rgbString) != 3 {
|
||||
slog.Error("Invalid RGB color format")
|
||||
return ""
|
||||
}
|
||||
|
||||
r, err := strconv.Atoi(string(rgbString[0]))
|
||||
if err != nil {
|
||||
slog.Error("Invalid value for R")
|
||||
return ""
|
||||
}
|
||||
|
||||
g, err := strconv.Atoi(string(rgbString[1]))
|
||||
if err != nil {
|
||||
slog.Error("Invalid value for G")
|
||||
return ""
|
||||
}
|
||||
|
||||
b, err := strconv.Atoi(string(rgbString[2]))
|
||||
if err != nil {
|
||||
slog.Error("Invalid value for B")
|
||||
return ""
|
||||
}
|
||||
|
||||
return strconv.Itoa(16 + (36 * r) + (6 * g) + b)
|
||||
}
|
||||
|
||||
var colorStrings = map[string]int{
|
||||
"black": 0,
|
||||
"red": 1,
|
||||
"green": 2,
|
||||
"yellow": 3,
|
||||
"blue": 4,
|
||||
"magenta": 5,
|
||||
"cyan": 6,
|
||||
"white": 7,
|
||||
"bright black": 8,
|
||||
"bright red": 9,
|
||||
"bright green": 10,
|
||||
"bright yellow": 11,
|
||||
"bright blue": 12,
|
||||
"bright magenta": 13,
|
||||
"bright cyan": 14,
|
||||
"bright white": 15,
|
||||
}
|
||||
85
internal/common/sync.go
Normal file
85
internal/common/sync.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"tasksquire/internal/taskwarrior"
|
||||
"tasksquire/internal/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)
|
||||
}
|
||||
}
|
||||
280
internal/common/task.go
Normal file
280
internal/common/task.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Annotation struct {
|
||||
Entry string `json:"entry,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
func (a Annotation) String() string {
|
||||
return fmt.Sprintf("%s %s", a.Entry, a.Description)
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
Id int64 `json:"id,omitempty"`
|
||||
Uuid string `json:"uuid,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Project string `json:"project"`
|
||||
Priority string `json:"priority"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Tags []string `json:"tags"`
|
||||
Depends []string `json:"depends,omitempty"`
|
||||
DependsIds string `json:"-"`
|
||||
Urgency float32 `json:"urgency,omitempty"`
|
||||
Parent string `json:"parenttask,omitempty"`
|
||||
Due string `json:"due,omitempty"`
|
||||
Wait string `json:"wait,omitempty"`
|
||||
Scheduled string `json:"scheduled,omitempty"`
|
||||
Until string `json:"until,omitempty"`
|
||||
Start string `json:"start,omitempty"`
|
||||
End string `json:"end,omitempty"`
|
||||
Entry string `json:"entry,omitempty"`
|
||||
Modified string `json:"modified,omitempty"`
|
||||
Recur string `json:"recur,omitempty"`
|
||||
Annotations []Annotation `json:"annotations,omitempty"`
|
||||
}
|
||||
|
||||
func (t *Task) GetString(fieldWFormat string) string {
|
||||
field, format, _ := strings.Cut(fieldWFormat, ".")
|
||||
|
||||
switch field {
|
||||
case "id":
|
||||
return strconv.FormatInt(t.Id, 10)
|
||||
|
||||
case "uuid":
|
||||
if format == "short" {
|
||||
return t.Uuid[:8]
|
||||
}
|
||||
return t.Uuid
|
||||
|
||||
case "description":
|
||||
switch format {
|
||||
case "desc":
|
||||
return t.Description
|
||||
case "oneline":
|
||||
if len(t.Annotations) == 0 {
|
||||
return t.Description
|
||||
} else {
|
||||
var annotations []string
|
||||
for _, a := range t.Annotations {
|
||||
annotations = append(annotations, a.String())
|
||||
}
|
||||
return fmt.Sprintf("%s %s", t.Description, strings.Join(annotations, " "))
|
||||
}
|
||||
case "truncated":
|
||||
if len(t.Description) > 20 {
|
||||
return t.Description[:20] + "..."
|
||||
} else {
|
||||
return t.Description
|
||||
}
|
||||
case "count":
|
||||
return fmt.Sprintf("%s [%d]", t.Description, len(t.Annotations))
|
||||
case "truncated_count":
|
||||
if len(t.Description) > 20 {
|
||||
return fmt.Sprintf("%s... [%d]", t.Description[:20], len(t.Annotations))
|
||||
} else {
|
||||
return fmt.Sprintf("%s [%d]", t.Description, len(t.Annotations))
|
||||
}
|
||||
}
|
||||
|
||||
if len(t.Annotations) == 0 {
|
||||
return t.Description
|
||||
} else {
|
||||
var annotations []string
|
||||
for _, a := range t.Annotations {
|
||||
annotations = append(annotations, a.String())
|
||||
}
|
||||
// TODO support for multiline?
|
||||
return fmt.Sprintf("%s %s", t.Description, strings.Join(annotations, " "))
|
||||
}
|
||||
|
||||
case "project":
|
||||
switch format {
|
||||
case "parent":
|
||||
parent, _, _ := strings.Cut(t.Project, ".")
|
||||
return parent
|
||||
case "indented":
|
||||
return fmt.Sprintf(" %s", t.Project)
|
||||
}
|
||||
return t.Project
|
||||
|
||||
case "priority":
|
||||
return t.Priority
|
||||
|
||||
case "status":
|
||||
return t.Status
|
||||
|
||||
case "tags":
|
||||
switch format {
|
||||
case "count":
|
||||
return strconv.Itoa(len(t.Tags))
|
||||
case "indicator":
|
||||
if len(t.Tags) > 0 {
|
||||
return "+"
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return strings.Join(t.Tags, " ")
|
||||
|
||||
case "parenttask":
|
||||
if format == "short" {
|
||||
return t.Parent[:8]
|
||||
}
|
||||
return t.Parent
|
||||
|
||||
case "urgency":
|
||||
if format == "integer" {
|
||||
return strconv.Itoa(int(t.Urgency))
|
||||
}
|
||||
return fmt.Sprintf("%.2f", t.Urgency)
|
||||
|
||||
case "due":
|
||||
return formatDate(t.Due, format)
|
||||
|
||||
case "wait":
|
||||
return formatDate(t.Wait, format)
|
||||
|
||||
case "scheduled":
|
||||
return formatDate(t.Scheduled, format)
|
||||
|
||||
case "end":
|
||||
return formatDate(t.End, format)
|
||||
|
||||
case "entry":
|
||||
return formatDate(t.Entry, format)
|
||||
|
||||
case "modified":
|
||||
return formatDate(t.Modified, format)
|
||||
|
||||
case "start":
|
||||
return formatDate(t.Start, format)
|
||||
|
||||
case "until":
|
||||
return formatDate(t.Until, format)
|
||||
|
||||
case "depends":
|
||||
switch format {
|
||||
case "count":
|
||||
return strconv.Itoa(len(t.Depends))
|
||||
case "indicator":
|
||||
if len(t.Depends) > 0 {
|
||||
return "D"
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return t.DependsIds
|
||||
|
||||
case "recur":
|
||||
return t.Recur
|
||||
|
||||
default:
|
||||
slog.Error("Field not implemented", "field", field)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
type Tasks []*Task
|
||||
|
||||
type Context struct {
|
||||
Name string
|
||||
Active bool
|
||||
ReadFilter string
|
||||
WriteFilter string
|
||||
}
|
||||
|
||||
type Contexts map[string]*Context
|
||||
|
||||
type Report struct {
|
||||
Name string
|
||||
Description string
|
||||
Labels []string
|
||||
Filter string
|
||||
Sort string
|
||||
Columns []string
|
||||
Context bool
|
||||
}
|
||||
|
||||
type Reports map[string]*Report
|
||||
|
||||
func formatDate(date string, format string) string {
|
||||
if date == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
dtformat := "20060102T150405Z"
|
||||
dt, err := time.Parse(dtformat, date)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse time", "error", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
switch format {
|
||||
case "formatted", "":
|
||||
return dt.Format("2006-01-02 15:04")
|
||||
// TODO: proper julian date formatting
|
||||
case "julian":
|
||||
return dt.Format("060102.1504")
|
||||
case "epoch":
|
||||
return strconv.FormatInt(dt.Unix(), 10)
|
||||
case "iso":
|
||||
return dt.Format("2006-01-02T150405Z")
|
||||
case "age":
|
||||
return parseDurationVague(time.Since(dt))
|
||||
case "relative":
|
||||
return parseDurationVague(time.Until(dt))
|
||||
// TODO: implement remaining
|
||||
case "remaining":
|
||||
return ""
|
||||
case "countdown":
|
||||
return parseCountdown(time.Since(dt))
|
||||
default:
|
||||
slog.Error("Date format not implemented", "format", format)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func parseDurationVague(d time.Duration) string {
|
||||
dur := d.Round(time.Second).Abs()
|
||||
days := dur.Hours() / 24
|
||||
|
||||
var formatted string
|
||||
if dur >= time.Hour*24*365 {
|
||||
formatted = fmt.Sprintf("%.1fy", days/365)
|
||||
} else if dur >= time.Hour*24*90 {
|
||||
formatted = strconv.Itoa(int(math.Round(days/30))) + "mo"
|
||||
} else if dur >= time.Hour*24*7 {
|
||||
formatted = strconv.Itoa(int(math.Round(days/7))) + "w"
|
||||
} else if dur >= time.Hour*24 {
|
||||
formatted = strconv.Itoa(int(days)) + "d"
|
||||
} else if dur >= time.Hour {
|
||||
formatted = strconv.Itoa(int(dur.Round(time.Hour).Hours())) + "h"
|
||||
} else if dur >= time.Minute {
|
||||
formatted = strconv.Itoa(int(dur.Round(time.Minute).Minutes())) + "min"
|
||||
} else if dur >= time.Second {
|
||||
formatted = strconv.Itoa(int(dur.Round(time.Second).Seconds())) + "s"
|
||||
}
|
||||
|
||||
if d < 0 {
|
||||
formatted = "-" + formatted
|
||||
}
|
||||
|
||||
return formatted
|
||||
}
|
||||
|
||||
func parseCountdown(d time.Duration) string {
|
||||
hours := int(d.Hours())
|
||||
minutes := int(d.Minutes()) % 60
|
||||
seconds := int(d.Seconds()) % 60
|
||||
|
||||
return fmt.Sprintf("%d:%02d:%02d", hours, minutes, seconds)
|
||||
}
|
||||
112
internal/pages/main.go
Normal file
112
internal/pages/main.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"tasksquire/internal/common"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
// "charm.land/bubbles/v2/key"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
type MainPage struct {
|
||||
common *common.Common
|
||||
activePage common.Component
|
||||
|
||||
taskPage common.Component
|
||||
timePage common.Component
|
||||
currentTab int
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func NewMainPage(common *common.Common) *MainPage {
|
||||
m := &MainPage{
|
||||
common: common,
|
||||
}
|
||||
|
||||
// m.taskPage = NewReportPage(common, common.TW.GetReport(common.TW.GetConfig().Get("uda.tasksquire.report.default")))
|
||||
// m.timePage = NewTimePage(common)
|
||||
//
|
||||
// m.activePage = m.taskPage
|
||||
m.currentTab = 0
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *MainPage) Init() tea.Cmd {
|
||||
// return tea.Batch(m.taskPage.Init(), m.timePage.Init())
|
||||
return tea.Batch()
|
||||
}
|
||||
|
||||
func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
// switch msg := msg.(type) {
|
||||
// case tea.WindowSizeMsg:
|
||||
// m.width = msg.Width
|
||||
// m.height = msg.Height
|
||||
// m.common.SetSize(msg.Width, msg.Height)
|
||||
//
|
||||
// tabHeight := lipgloss.Height(m.renderTabBar())
|
||||
// contentHeight := msg.Height - tabHeight
|
||||
// if contentHeight < 0 {
|
||||
// contentHeight = 0
|
||||
// }
|
||||
//
|
||||
// newMsg := tea.WindowSizeMsg{Width: msg.Width, Height: contentHeight}
|
||||
// activePage, cmd := m.activePage.Update(newMsg)
|
||||
// m.activePage = activePage.(common.Component)
|
||||
// return m, cmd
|
||||
//
|
||||
// case tea.KeyMsg:
|
||||
// // Only handle tab key for page switching when at the top level (no subpages active)
|
||||
// if key.Matches(msg, m.common.Keymap.Next) && !m.common.HasSubpages() {
|
||||
// if m.activePage == m.taskPage {
|
||||
// m.activePage = m.timePage
|
||||
// m.currentTab = 1
|
||||
// } else {
|
||||
// m.activePage = m.taskPage
|
||||
// m.currentTab = 0
|
||||
// }
|
||||
//
|
||||
// tabHeight := lipgloss.Height(m.renderTabBar())
|
||||
// contentHeight := m.height - tabHeight
|
||||
// if contentHeight < 0 {
|
||||
// contentHeight = 0
|
||||
// }
|
||||
// m.activePage.SetSize(m.width, contentHeight)
|
||||
//
|
||||
// // Trigger a refresh/init on switch? Maybe not needed if we keep state.
|
||||
// // But we might want to refresh data.
|
||||
// return m, m.activePage.Init()
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// activePage, cmd := m.activePage.Update(msg)
|
||||
// m.activePage = activePage.(common.Component)
|
||||
//
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *MainPage) renderTabBar() string {
|
||||
var tabs []string
|
||||
headers := []string{"Tasks", "Time"}
|
||||
|
||||
for i, header := range headers {
|
||||
style := m.common.Styles.Tab
|
||||
if m.currentTab == i {
|
||||
style = m.common.Styles.ActiveTab
|
||||
}
|
||||
tabs = append(tabs, style.Render(header))
|
||||
}
|
||||
|
||||
row := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
|
||||
return m.common.Styles.TabBar.Width(m.common.Width()).Render(row)
|
||||
}
|
||||
|
||||
func (m *MainPage) View() tea.View {
|
||||
v := tea.NewView("test")
|
||||
v.AltScreen = true
|
||||
return v
|
||||
// return lipgloss.JoinVertical(lipgloss.Left, m.renderTabBar(), m.activePage.View())
|
||||
}
|
||||
64
internal/taskwarrior/config.go
Normal file
64
internal/taskwarrior/config.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package taskwarrior
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type TWConfig struct {
|
||||
config map[string]string
|
||||
}
|
||||
|
||||
var (
|
||||
defaultConfig = map[string]string{
|
||||
"uda.tasksquire.report.default": "next",
|
||||
"uda.tasksquire.tag.default": "next",
|
||||
"uda.tasksquire.tags.default": "mngmnt,ops,low_energy,cust,delegate,code,comm,research",
|
||||
"uda.tasksquire.picker.filter_by_default": "yes",
|
||||
}
|
||||
)
|
||||
|
||||
func NewConfig(config []string) *TWConfig {
|
||||
cfg := parseConfig(config)
|
||||
|
||||
for key, value := range defaultConfig {
|
||||
if _, ok := cfg[key]; !ok {
|
||||
cfg[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return &TWConfig{
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *TWConfig) GetConfig() map[string]string {
|
||||
return tc.config
|
||||
}
|
||||
|
||||
func (tc *TWConfig) Get(key string) string {
|
||||
if _, ok := tc.config[key]; !ok {
|
||||
slog.Debug(fmt.Sprintf("Key not found in config: %s", key))
|
||||
return ""
|
||||
}
|
||||
|
||||
return tc.config[key]
|
||||
}
|
||||
|
||||
func parseConfig(config []string) map[string]string {
|
||||
configMap := make(map[string]string)
|
||||
|
||||
for _, line := range config {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
|
||||
configMap[key] = value
|
||||
}
|
||||
|
||||
return configMap
|
||||
}
|
||||
556
internal/taskwarrior/models.go
Normal file
556
internal/taskwarrior/models.go
Normal file
@@ -0,0 +1,556 @@
|
||||
package taskwarrior
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
dtformat = "20060102T150405Z"
|
||||
)
|
||||
|
||||
type UdaType string
|
||||
|
||||
const (
|
||||
UdaTypeString UdaType = "string"
|
||||
UdaTypeDate UdaType = "date"
|
||||
UdaTypeNumeric UdaType = "numeric"
|
||||
UdaTypeDuration UdaType = "duration"
|
||||
)
|
||||
|
||||
type Uda struct {
|
||||
Name string
|
||||
Type UdaType
|
||||
Label string
|
||||
Values []string
|
||||
Default string
|
||||
}
|
||||
|
||||
type Annotation struct {
|
||||
Entry string `json:"entry,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
func (a Annotation) String() string {
|
||||
return fmt.Sprintf("%s %s", a.Entry, a.Description)
|
||||
}
|
||||
|
||||
type Tasks []*Task
|
||||
|
||||
type Task struct {
|
||||
Id int64 `json:"id,omitempty"`
|
||||
Uuid string `json:"uuid,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Project string `json:"project"`
|
||||
Priority string `json:"priority,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Tags []string `json:"tags"`
|
||||
VirtualTags []string `json:"-"`
|
||||
Depends []string `json:"depends,omitempty"`
|
||||
DependsIds string `json:"-"`
|
||||
Urgency float32 `json:"urgency,omitempty"`
|
||||
Parent string `json:"parenttask,omitempty"`
|
||||
Due string `json:"due,omitempty"`
|
||||
Wait string `json:"wait,omitempty"`
|
||||
Scheduled string `json:"scheduled,omitempty"`
|
||||
Until string `json:"until,omitempty"`
|
||||
Start string `json:"start,omitempty"`
|
||||
End string `json:"end,omitempty"`
|
||||
Entry string `json:"entry,omitempty"`
|
||||
Modified string `json:"modified,omitempty"`
|
||||
Recur string `json:"recur,omitempty"`
|
||||
Annotations []Annotation `json:"annotations,omitempty"`
|
||||
Udas map[string]any `json:"-"`
|
||||
}
|
||||
|
||||
// TODO: fix pointer receiver
|
||||
func NewTask() Task {
|
||||
return Task{
|
||||
Tags: make([]string, 0),
|
||||
Depends: make([]string, 0),
|
||||
Udas: make(map[string]any),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Task) GetString(fieldWFormat string) string {
|
||||
field, format, _ := strings.Cut(fieldWFormat, ".")
|
||||
|
||||
switch field {
|
||||
case "id":
|
||||
return strconv.FormatInt(t.Id, 10)
|
||||
|
||||
case "uuid":
|
||||
if format == "short" {
|
||||
return t.Uuid[:8]
|
||||
}
|
||||
return t.Uuid
|
||||
|
||||
case "description":
|
||||
switch format {
|
||||
case "desc":
|
||||
return t.Description
|
||||
case "oneline":
|
||||
if len(t.Annotations) == 0 {
|
||||
return t.Description
|
||||
} else {
|
||||
var annotations []string
|
||||
for _, a := range t.Annotations {
|
||||
annotations = append(annotations, a.String())
|
||||
}
|
||||
return fmt.Sprintf("%s %s", t.Description, strings.Join(annotations, " "))
|
||||
}
|
||||
case "truncated":
|
||||
if len(t.Description) > 20 {
|
||||
return t.Description[:20] + "..."
|
||||
} else {
|
||||
return t.Description
|
||||
}
|
||||
case "count":
|
||||
return fmt.Sprintf("%s [%d]", t.Description, len(t.Annotations))
|
||||
case "truncated_count":
|
||||
if len(t.Description) > 20 {
|
||||
return fmt.Sprintf("%s... [%d]", t.Description[:20], len(t.Annotations))
|
||||
} else {
|
||||
return fmt.Sprintf("%s [%d]", t.Description, len(t.Annotations))
|
||||
}
|
||||
}
|
||||
|
||||
if t.Udas["details"] != nil && t.Udas["details"] != "" {
|
||||
return fmt.Sprintf("%s [D]", t.Description)
|
||||
} else {
|
||||
return t.Description
|
||||
}
|
||||
|
||||
// if len(t.Annotations) == 0 {
|
||||
// return t.Description
|
||||
// } else {
|
||||
// // var annotations []string
|
||||
// // for _, a := range t.Annotations {
|
||||
// // annotations = append(annotations, a.String())
|
||||
// // }
|
||||
// // return fmt.Sprintf("%s\n%s", t.Description, strings.Join(annotations, "\n"))
|
||||
|
||||
// // TODO enable support for multiline in table
|
||||
// return fmt.Sprintf("%s [%d]", t.Description, len(t.Annotations))
|
||||
// }
|
||||
|
||||
case "project":
|
||||
switch format {
|
||||
case "parent":
|
||||
parent, _, _ := strings.Cut(t.Project, ".")
|
||||
return parent
|
||||
case "indented":
|
||||
return fmt.Sprintf(" %s", t.Project)
|
||||
}
|
||||
return t.Project
|
||||
|
||||
case "priority":
|
||||
return t.Priority
|
||||
|
||||
case "status":
|
||||
return t.Status
|
||||
|
||||
case "tags":
|
||||
switch format {
|
||||
case "count":
|
||||
return strconv.Itoa(len(t.Tags))
|
||||
case "indicator":
|
||||
if len(t.Tags) > 0 {
|
||||
return "+"
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return strings.Join(t.Tags, " ")
|
||||
|
||||
case "parenttask":
|
||||
if format == "short" {
|
||||
return t.Parent[:8]
|
||||
}
|
||||
return t.Parent
|
||||
|
||||
case "urgency":
|
||||
if format == "integer" {
|
||||
return strconv.Itoa(int(t.Urgency))
|
||||
}
|
||||
return fmt.Sprintf("%.2f", t.Urgency)
|
||||
|
||||
case "due":
|
||||
return formatDate(t.Due, format)
|
||||
|
||||
case "wait":
|
||||
return formatDate(t.Wait, format)
|
||||
|
||||
case "scheduled":
|
||||
return formatDate(t.Scheduled, format)
|
||||
|
||||
case "end":
|
||||
return formatDate(t.End, format)
|
||||
|
||||
case "entry":
|
||||
return formatDate(t.Entry, format)
|
||||
|
||||
case "modified":
|
||||
return formatDate(t.Modified, format)
|
||||
|
||||
case "start":
|
||||
return formatDate(t.Start, format)
|
||||
|
||||
case "until":
|
||||
return formatDate(t.Until, format)
|
||||
|
||||
case "depends":
|
||||
switch format {
|
||||
case "count":
|
||||
return strconv.Itoa(len(t.Depends))
|
||||
case "indicator":
|
||||
if len(t.Depends) > 0 {
|
||||
return "D"
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return t.DependsIds
|
||||
|
||||
case "recur":
|
||||
return t.Recur
|
||||
|
||||
default:
|
||||
// TODO: format according to UDA type
|
||||
if val, ok := t.Udas[field]; ok {
|
||||
if strVal, ok := val.(string); ok {
|
||||
return strVal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slog.Error("Field not implemented", "field", field)
|
||||
return ""
|
||||
}
|
||||
|
||||
func (t *Task) GetDate(field string) time.Time {
|
||||
var dateString string
|
||||
switch field {
|
||||
case "due":
|
||||
dateString = t.Due
|
||||
case "wait":
|
||||
dateString = t.Wait
|
||||
case "scheduled":
|
||||
dateString = t.Scheduled
|
||||
case "until":
|
||||
dateString = t.Until
|
||||
case "start":
|
||||
dateString = t.Start
|
||||
case "end":
|
||||
dateString = t.End
|
||||
case "entry":
|
||||
dateString = t.Entry
|
||||
case "modified":
|
||||
dateString = t.Modified
|
||||
default:
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
if dateString == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
dt, err := time.Parse(dtformat, dateString)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse time", "error", err)
|
||||
return time.Time{}
|
||||
}
|
||||
return dt
|
||||
}
|
||||
|
||||
func (t *Task) HasTag(tag string) bool {
|
||||
for _, t := range t.Tags {
|
||||
if t == tag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (t *Task) AddTag(tag string) {
|
||||
if !t.HasTag(tag) {
|
||||
t.Tags = append(t.Tags, tag)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Task) RemoveTag(tag string) {
|
||||
for i, ttag := range t.Tags {
|
||||
if ttag == tag {
|
||||
t.Tags = append(t.Tags[:i], t.Tags[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Task) UnmarshalJSON(data []byte) error {
|
||||
type Alias Task
|
||||
task := Alias{}
|
||||
|
||||
if err := json.Unmarshal(data, &task); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*t = Task(task)
|
||||
|
||||
m := make(map[string]any)
|
||||
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
delete(m, "id")
|
||||
delete(m, "uuid")
|
||||
delete(m, "description")
|
||||
delete(m, "project")
|
||||
// delete(m, "priority")
|
||||
delete(m, "status")
|
||||
delete(m, "tags")
|
||||
delete(m, "depends")
|
||||
delete(m, "urgency")
|
||||
delete(m, "parenttask")
|
||||
delete(m, "due")
|
||||
delete(m, "wait")
|
||||
delete(m, "scheduled")
|
||||
delete(m, "until")
|
||||
delete(m, "start")
|
||||
delete(m, "end")
|
||||
delete(m, "entry")
|
||||
delete(m, "modified")
|
||||
delete(m, "recur")
|
||||
delete(m, "annotations")
|
||||
t.Udas = m
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Task) MarshalJSON() ([]byte, error) {
|
||||
type Alias Task
|
||||
task := Alias(*t)
|
||||
|
||||
knownFields, err := json.Marshal(task)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var knownMap map[string]any
|
||||
if err := json.Unmarshal(knownFields, &knownMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for key, value := range t.Udas {
|
||||
if value != nil && value != "" {
|
||||
knownMap[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return json.Marshal(knownMap)
|
||||
}
|
||||
|
||||
type Context struct {
|
||||
Name string
|
||||
Active bool
|
||||
ReadFilter string
|
||||
WriteFilter string
|
||||
}
|
||||
|
||||
type Contexts map[string]*Context
|
||||
|
||||
type Report struct {
|
||||
Name string
|
||||
Description string
|
||||
Labels []string
|
||||
Filter string
|
||||
Sort string
|
||||
Columns []string
|
||||
Context bool
|
||||
}
|
||||
|
||||
type Reports map[string]*Report
|
||||
|
||||
func formatDate(date string, format string) string {
|
||||
if date == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
dt, err := time.Parse(dtformat, date)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse time", "error", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
switch format {
|
||||
case "formatted", "":
|
||||
return dt.Format("2006-01-02 15:04")
|
||||
// TODO: proper julian date formatting
|
||||
case "julian":
|
||||
return dt.Format("060102.1504")
|
||||
case "epoch":
|
||||
return strconv.FormatInt(dt.Unix(), 10)
|
||||
case "iso":
|
||||
return dt.Format("2006-01-02T150405Z")
|
||||
case "age":
|
||||
return parseDurationVague(time.Since(dt))
|
||||
case "relative":
|
||||
return parseDurationVague(time.Until(dt))
|
||||
// TODO: implement remaining
|
||||
case "remaining":
|
||||
return ""
|
||||
case "countdown":
|
||||
return parseCountdown(time.Since(dt))
|
||||
default:
|
||||
slog.Error("Date format not implemented", "format", format)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func parseDurationVague(d time.Duration) string {
|
||||
dur := d.Round(time.Second).Abs()
|
||||
days := dur.Hours() / 24
|
||||
|
||||
var formatted string
|
||||
if dur >= time.Hour*24*365 {
|
||||
formatted = fmt.Sprintf("%.1fy", days/365)
|
||||
} else if dur >= time.Hour*24*90 {
|
||||
formatted = strconv.Itoa(int(math.Round(days/30))) + "mo"
|
||||
} else if dur >= time.Hour*24*7 {
|
||||
formatted = strconv.Itoa(int(math.Round(days/7))) + "w"
|
||||
} else if dur >= time.Hour*24 {
|
||||
formatted = strconv.Itoa(int(days)) + "d"
|
||||
} else if dur >= time.Hour {
|
||||
formatted = strconv.Itoa(int(dur.Round(time.Hour).Hours())) + "h"
|
||||
} else if dur >= time.Minute {
|
||||
formatted = strconv.Itoa(int(dur.Round(time.Minute).Minutes())) + "min"
|
||||
} else if dur >= time.Second {
|
||||
formatted = strconv.Itoa(int(dur.Round(time.Second).Seconds())) + "s"
|
||||
}
|
||||
|
||||
if d < 0 {
|
||||
formatted = "-" + formatted
|
||||
}
|
||||
|
||||
return formatted
|
||||
}
|
||||
|
||||
func parseCountdown(d time.Duration) string {
|
||||
hours := int(d.Hours())
|
||||
minutes := int(d.Minutes()) % 60
|
||||
seconds := int(d.Seconds()) % 60
|
||||
|
||||
return fmt.Sprintf("%d:%02d:%02d", hours, minutes, seconds)
|
||||
}
|
||||
|
||||
var (
|
||||
dateFormats = []string{
|
||||
"2006-01-02",
|
||||
"2006-01-02T15:04",
|
||||
"20060102T150405Z",
|
||||
}
|
||||
|
||||
specialDateFormats = []string{
|
||||
"",
|
||||
"now",
|
||||
"today",
|
||||
"sod",
|
||||
"eod",
|
||||
"yesterday",
|
||||
"tomorrow",
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
"sunday",
|
||||
"mon",
|
||||
"tue",
|
||||
"wed",
|
||||
"thu",
|
||||
"fri",
|
||||
"sat",
|
||||
"sun",
|
||||
"soy",
|
||||
"eoy",
|
||||
"soq",
|
||||
"eoq",
|
||||
"som",
|
||||
"eom",
|
||||
"socm",
|
||||
"eocm",
|
||||
"sow",
|
||||
"eow",
|
||||
"socw",
|
||||
"eocw",
|
||||
"soww",
|
||||
"eoww",
|
||||
"1st",
|
||||
"2nd",
|
||||
"3rd",
|
||||
"4th",
|
||||
"5th",
|
||||
"6th",
|
||||
"7th",
|
||||
"8th",
|
||||
"9th",
|
||||
"10th",
|
||||
"11th",
|
||||
"12th",
|
||||
"13th",
|
||||
"14th",
|
||||
"15th",
|
||||
"16th",
|
||||
"17th",
|
||||
"18th",
|
||||
"19th",
|
||||
"20th",
|
||||
"21st",
|
||||
"22nd",
|
||||
"23rd",
|
||||
"24th",
|
||||
"25th",
|
||||
"26th",
|
||||
"27th",
|
||||
"28th",
|
||||
"29th",
|
||||
"30th",
|
||||
"31st",
|
||||
}
|
||||
)
|
||||
|
||||
func ValidateDate(s string) error {
|
||||
|
||||
for _, f := range dateFormats {
|
||||
if _, err := time.Parse(f, s); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
for _, f := range specialDateFormats {
|
||||
if s == f {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("invalid date")
|
||||
}
|
||||
|
||||
func ValidateNumeric(s string) error {
|
||||
if _, err := strconv.ParseFloat(s, 64); err != nil {
|
||||
return fmt.Errorf("invalid number")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateDuration(s string) error {
|
||||
// TODO: implement duration validation
|
||||
return nil
|
||||
}
|
||||
81
internal/taskwarrior/models_test.go
Normal file
81
internal/taskwarrior/models_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package taskwarrior
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTask_GetString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
task Task
|
||||
fieldWFormat string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "Priority",
|
||||
task: Task{
|
||||
Priority: "H",
|
||||
},
|
||||
fieldWFormat: "priority",
|
||||
want: "H",
|
||||
},
|
||||
{
|
||||
name: "Description",
|
||||
task: Task{
|
||||
Description: "Buy milk",
|
||||
},
|
||||
fieldWFormat: "description.desc",
|
||||
want: "Buy milk",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.task.GetString(tt.fieldWFormat); got != tt.want {
|
||||
t.Errorf("Task.GetString() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTask_GetDate(t *testing.T) {
|
||||
validDate := "20230101T120000Z"
|
||||
parsedValid, _ := time.Parse("20060102T150405Z", validDate)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
task Task
|
||||
field string
|
||||
want time.Time
|
||||
}{
|
||||
{
|
||||
name: "Due date valid",
|
||||
task: Task{
|
||||
Due: validDate,
|
||||
},
|
||||
field: "due",
|
||||
want: parsedValid,
|
||||
},
|
||||
{
|
||||
name: "Due date empty",
|
||||
task: Task{},
|
||||
field: "due",
|
||||
want: time.Time{},
|
||||
},
|
||||
{
|
||||
name: "Unknown field",
|
||||
task: Task{Due: validDate},
|
||||
field: "unknown",
|
||||
want: time.Time{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.task.GetDate(tt.field); !got.Equal(tt.want) {
|
||||
t.Errorf("Task.GetDate() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
670
internal/taskwarrior/taskwarrior.go
Normal file
670
internal/taskwarrior/taskwarrior.go
Normal file
@@ -0,0 +1,670 @@
|
||||
// TODO: error handling
|
||||
// TODO: split combinedOutput and handle stderr differently
|
||||
// TODO: reorder functions
|
||||
package taskwarrior
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
twBinary = "task"
|
||||
)
|
||||
|
||||
var (
|
||||
nonStandardReports = map[string]struct{}{
|
||||
"burndown.daily": {},
|
||||
"burndown.monthly": {},
|
||||
"burndown.weekly": {},
|
||||
"calendar": {},
|
||||
"colors": {},
|
||||
"export": {},
|
||||
"ghistory.annual": {},
|
||||
"ghistory.monthly": {},
|
||||
"history.annual": {},
|
||||
"history.monthly": {},
|
||||
"information": {},
|
||||
"summary": {},
|
||||
"timesheet": {},
|
||||
}
|
||||
|
||||
virtualTags = map[string]struct{}{
|
||||
"ACTIVE": {},
|
||||
"ANNOTATED": {},
|
||||
"BLOCKED": {},
|
||||
"BLOCKING": {},
|
||||
"CHILD": {},
|
||||
"COMPLETED": {},
|
||||
"DELETED": {},
|
||||
"DUE": {},
|
||||
"DUETODAY": {},
|
||||
"INSTANCE": {},
|
||||
"LATEST": {},
|
||||
"MONTH": {},
|
||||
"ORPHAN": {},
|
||||
"OVERDUE": {},
|
||||
"PARENT": {},
|
||||
"PENDING": {},
|
||||
"PRIORITY": {},
|
||||
"PROJECT": {},
|
||||
"QUARTER": {},
|
||||
"READY": {},
|
||||
"SCHEDULED": {},
|
||||
"TAGGED": {},
|
||||
"TEMPLATE": {},
|
||||
"TODAY": {},
|
||||
"TOMORROW": {},
|
||||
"UDA": {},
|
||||
"UNBLOCKED": {},
|
||||
"UNTIL": {},
|
||||
"WAITING": {},
|
||||
"WEEK": {},
|
||||
"YEAR": {},
|
||||
"YESTERDAY": {},
|
||||
}
|
||||
)
|
||||
|
||||
type TaskWarrior interface {
|
||||
GetConfig() *TWConfig
|
||||
|
||||
GetActiveContext() *Context
|
||||
GetContext(context string) *Context
|
||||
GetContexts() Contexts
|
||||
SetContext(context *Context) error
|
||||
|
||||
GetProjects() []string
|
||||
|
||||
GetPriorities() []string
|
||||
|
||||
GetTags() []string
|
||||
|
||||
GetReport(report string) *Report
|
||||
GetReports() Reports
|
||||
|
||||
GetUdas() []Uda
|
||||
|
||||
GetTasks(report *Report, filter ...string) Tasks
|
||||
// AddTask(task *Task) error
|
||||
ImportTask(task *Task)
|
||||
SetTaskDone(task *Task)
|
||||
DeleteTask(task *Task)
|
||||
StartTask(task *Task)
|
||||
StopTask(task *Task)
|
||||
StopActiveTasks()
|
||||
GetInformation(task *Task) string
|
||||
AddTaskAnnotation(uuid string, annotation string)
|
||||
|
||||
Undo()
|
||||
}
|
||||
|
||||
type TaskwarriorInterop struct {
|
||||
configLocation string
|
||||
defaultArgs []string
|
||||
config *TWConfig
|
||||
reports Reports
|
||||
contexts Contexts
|
||||
ctx context.Context
|
||||
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
func NewTaskwarriorInterop(ctx context.Context, configLocation string) *TaskwarriorInterop {
|
||||
if _, err := exec.LookPath(twBinary); err != nil {
|
||||
slog.Error("Taskwarrior not found")
|
||||
return nil
|
||||
}
|
||||
defaultArgs := []string{fmt.Sprintf("rc=%s", configLocation), "rc.verbose=nothing", "rc.dependency.confirmation=no", "rc.recurrence.confirmation=no", "rc.confirmation=no", "rc.json.array=1"}
|
||||
|
||||
ts := &TaskwarriorInterop{
|
||||
configLocation: configLocation,
|
||||
defaultArgs: defaultArgs,
|
||||
ctx: ctx,
|
||||
mutex: sync.Mutex{},
|
||||
}
|
||||
ts.config = ts.extractConfig()
|
||||
if ts.config == nil {
|
||||
slog.Error("Failed to extract config - taskwarrior commands are failing. Check your taskrc file for syntax errors.")
|
||||
return nil
|
||||
}
|
||||
ts.reports = ts.extractReports()
|
||||
ts.contexts = ts.extractContexts()
|
||||
|
||||
return ts
|
||||
}
|
||||
|
||||
func (ts *TaskwarriorInterop) GetConfig() *TWConfig {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
return ts.config
|
||||
}
|
||||
|
||||
func (ts *TaskwarriorInterop) GetTasks(report *Report, filter ...string) Tasks {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
args := ts.defaultArgs
|
||||
|
||||
if report != nil && report.Context {
|
||||
for _, context := range ts.contexts {
|
||||
if context.Active && context.Name != "none" {
|
||||
args = append(args, context.ReadFilter)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if filter != nil {
|
||||
args = append(args, filter...)
|
||||
}
|
||||
|
||||
exportArgs := []string{"export"}
|
||||
if report != nil && report.Name != "" {
|
||||
exportArgs = append(exportArgs, report.Name)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(args, exportArgs...)...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
if ts.ctx.Err() == context.Canceled {
|
||||
return nil
|
||||
}
|
||||
slog.Error("Failed getting report", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
tasks := make(Tasks, 0)
|
||||
err = json.Unmarshal(output, &tasks)
|
||||
if err != nil {
|
||||
slog.Error("Failed unmarshalling tasks", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, task := range tasks {
|
||||
if len(task.Depends) > 0 {
|
||||
ids := make([]string, len(task.Depends))
|
||||
for i, dependUuid := range task.Depends {
|
||||
ids[i] = ts.getIds([]string{fmt.Sprintf("uuid:%s", dependUuid)})
|
||||
}
|
||||
|
||||
task.DependsIds = strings.Join(ids, " ")
|
||||
}
|
||||
}
|
||||
|
||||
return tasks
|
||||
}
|
||||
|
||||
func (ts *TaskwarriorInterop) getIds(filter []string) string {
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(append(ts.defaultArgs, filter...), "_ids")...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("Failed getting field", "error", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func (ts *TaskwarriorInterop) GetContext(context string) *Context {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
if context == "" {
|
||||
context = "none"
|
||||
}
|
||||
|
||||
if context, ok := ts.contexts[context]; ok {
|
||||
return context
|
||||
} else {
|
||||
slog.Error("Context not found", "name", context)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (ts *TaskwarriorInterop) GetActiveContext() *Context {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
for _, context := range ts.contexts {
|
||||
if context.Active {
|
||||
return context
|
||||
}
|
||||
}
|
||||
|
||||
return ts.contexts["none"]
|
||||
}
|
||||
|
||||
func (ts *TaskwarriorInterop) GetContexts() Contexts {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
return ts.contexts
|
||||
}
|
||||
|
||||
func (ts *TaskwarriorInterop) GetProjects() []string {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_projects"}...)...)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("Failed getting projects", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
projects := make([]string, 0)
|
||||
for _, project := range strings.Split(string(output), "\n") {
|
||||
if project != "" {
|
||||
projects = append(projects, project)
|
||||
}
|
||||
}
|
||||
|
||||
slices.Sort(projects)
|
||||
|
||||
return projects
|
||||
}
|
||||
|
||||
func (ts *TaskwarriorInterop) GetPriorities() []string {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
priorities := make([]string, 0)
|
||||
for _, priority := range strings.Split(ts.config.Get("uda.priority.values"), ",") {
|
||||
if priority != "" {
|
||||
priorities = append(priorities, priority)
|
||||
}
|
||||
}
|
||||
|
||||
return priorities
|
||||
}
|
||||
|
||||
func (ts *TaskwarriorInterop) GetTags() []string {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_tags"}...)...)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("Failed getting tags", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
tags := make([]string, 0)
|
||||
tagSet := make(map[string]struct{})
|
||||
|
||||
for _, tag := range strings.Split(string(output), "\n") {
|
||||
if _, ok := virtualTags[tag]; !ok && tag != "" {
|
||||
tags = append(tags, tag)
|
||||
tagSet[tag] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
for _, tag := range strings.Split(ts.config.Get("uda.TaskwarriorInterop.tags.default"), ",") {
|
||||
if _, ok := tagSet[tag]; !ok {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
}
|
||||
|
||||
slices.Sort(tags)
|
||||
|
||||
return tags
|
||||
}
|
||||
|
||||
func (ts *TaskwarriorInterop) GetReport(report string) *Report {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
return ts.reports[report]
|
||||
}
|
||||
|
||||
func (ts *TaskwarriorInterop) GetReports() Reports {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
return ts.reports
|
||||
}
|
||||
|
||||
func (ts *TaskwarriorInterop) GetUdas() []Uda {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "_udas")...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
if ts.ctx.Err() == context.Canceled {
|
||||
return nil
|
||||
}
|
||||
slog.Error("Failed getting UDAs", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
udas := make([]Uda, 0)
|
||||
for _, uda := range strings.Split(string(output), "\n") {
|
||||
if uda != "" {
|
||||
udatype := UdaType(ts.config.Get(fmt.Sprintf("uda.%s.type", uda)))
|
||||
if udatype == "" {
|
||||
slog.Error("UDA type not found", "uda", uda)
|
||||
continue
|
||||
}
|
||||
|
||||
label := ts.config.Get(fmt.Sprintf("uda.%s.label", uda))
|
||||
values := strings.Split(ts.config.Get(fmt.Sprintf("uda.%s.values", uda)), ",")
|
||||
def := ts.config.Get(fmt.Sprintf("uda.%s.default", uda))
|
||||
|
||||
uda := Uda{
|
||||
Name: uda,
|
||||
Label: label,
|
||||
Type: udatype,
|
||||
Values: values,
|
||||
Default: def,
|
||||
}
|
||||
|
||||
udas = append(udas, uda)
|
||||
}
|
||||
}
|
||||
|
||||
return udas
|
||||
}
|
||||
|
||||
func (ts *TaskwarriorInterop) SetContext(context *Context) error {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
if context.Name == "none" && ts.contexts["none"].Active {
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, []string{"context", context.Name}...)
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Error("Failed setting context", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO: optimize this; there should be no need to re-extract everything
|
||||
ts.config = ts.extractConfig()
|
||||
ts.contexts = ts.extractContexts()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// func (ts *TaskwarriorInterop) AddTask(task *Task) error {
|
||||
// ts.mutex.Lock()
|
||||
// defer ts.mutex.Unlock()
|
||||
|
||||
// addArgs := []string{"add"}
|
||||
|
||||
// if task.Description == "" {
|
||||
// slog.Error("Task description is required")
|
||||
// return nil
|
||||
// } else {
|
||||
// addArgs = append(addArgs, task.Description)
|
||||
// }
|
||||
// if task.Priority != "" && task.Priority != "(none)" {
|
||||
// addArgs = append(addArgs, fmt.Sprintf("priority:%s", task.Priority))
|
||||
// }
|
||||
// if task.Project != "" && task.Project != "(none)" {
|
||||
// addArgs = append(addArgs, fmt.Sprintf("project:%s", task.Project))
|
||||
// }
|
||||
// if task.Tags != nil {
|
||||
// for _, tag := range task.Tags {
|
||||
// addArgs = append(addArgs, fmt.Sprintf("+%s", tag))
|
||||
// }
|
||||
// }
|
||||
// if task.Due != "" {
|
||||
// addArgs = append(addArgs, fmt.Sprintf("due:%s", task.Due))
|
||||
// }
|
||||
|
||||
// cmd := exec.Command(twBinary, append(ts.defaultArgs, addArgs...)...)
|
||||
// err := cmd.Run()
|
||||
// if err != nil {
|
||||
// slog.Error("Failed adding task:", err)
|
||||
// }
|
||||
|
||||
// // TODO remove error?
|
||||
// return nil
|
||||
// }
|
||||
|
||||
// TODO error handling
|
||||
func (ts *TaskwarriorInterop) ImportTask(task *Task) {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
tasks, err := json.Marshal(Tasks{task})
|
||||
if err != nil {
|
||||
slog.Error("Failed marshalling task", "error", err)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"import", "-"}...)...)
|
||||
cmd.Stdin = bytes.NewBuffer(tasks)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
if ts.ctx.Err() == context.Canceled {
|
||||
return
|
||||
}
|
||||
slog.Error("Failed modifying task", "error", err, "output", string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func (ts *TaskwarriorInterop) SetTaskDone(task *Task) {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"done", task.Uuid}...)...)
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
slog.Error("Failed setting task done", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (ts *TaskwarriorInterop) DeleteTask(task *Task) {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{task.Uuid, "delete"}...)...)
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
slog.Error("Failed deleting task", "error", err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (ts *TaskwarriorInterop) Undo() {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"undo"}...)...)
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
slog.Error("Failed undoing task", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (ts *TaskwarriorInterop) StartTask(task *Task) {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"start", task.Uuid}...)...)
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
slog.Error("Failed starting task", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (ts *TaskwarriorInterop) StopTask(task *Task) {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"stop", task.Uuid}...)...)
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
slog.Error("Failed stopping task", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (ts *TaskwarriorInterop) StopActiveTasks() {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"+ACTIVE", "export"}...)...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
if ts.ctx.Err() == context.Canceled {
|
||||
return
|
||||
}
|
||||
slog.Error("Failed getting active tasks", "error", err, "output", string(output))
|
||||
return
|
||||
}
|
||||
|
||||
tasks := make(Tasks, 0)
|
||||
err = json.Unmarshal(output, &tasks)
|
||||
if err != nil {
|
||||
slog.Error("Failed unmarshalling active tasks", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, task := range tasks {
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"stop", task.Uuid}...)...)
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
slog.Error("Failed stopping task", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ts *TaskwarriorInterop) GetInformation(task *Task) string {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{fmt.Sprintf("uuid:%s", task.Uuid), "information"}...)...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
if ts.ctx.Err() == context.Canceled {
|
||||
return ""
|
||||
}
|
||||
slog.Error("Failed getting task information", "error", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
return string(output)
|
||||
}
|
||||
|
||||
func (ts *TaskwarriorInterop) AddTaskAnnotation(uuid string, annotation string) {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{uuid, "annotate", annotation}...)...)
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
slog.Error("Failed adding annotation", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (ts *TaskwarriorInterop) extractConfig() *TWConfig {
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_show"}...)...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
if ts.ctx.Err() == context.Canceled {
|
||||
return nil
|
||||
}
|
||||
slog.Error("Failed getting config", "error", err, "output", string(output))
|
||||
return nil
|
||||
}
|
||||
|
||||
return NewConfig(strings.Split(string(output), "\n"))
|
||||
}
|
||||
|
||||
func (ts *TaskwarriorInterop) extractReports() Reports {
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_config"}...)...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
availableReports := extractReports(string(output))
|
||||
|
||||
reports := make(Reports)
|
||||
|
||||
for _, report := range availableReports {
|
||||
if _, ok := nonStandardReports[report]; ok {
|
||||
continue
|
||||
}
|
||||
reports[report] = &Report{
|
||||
Name: report,
|
||||
Description: ts.config.Get(fmt.Sprintf("report.%s.description", report)),
|
||||
Labels: strings.Split(ts.config.Get(fmt.Sprintf("report.%s.labels", report)), ","),
|
||||
Filter: ts.config.Get(fmt.Sprintf("report.%s.filter", report)),
|
||||
Sort: ts.config.Get(fmt.Sprintf("report.%s.sort", report)),
|
||||
Columns: strings.Split(ts.config.Get(fmt.Sprintf("report.%s.columns", report)), ","),
|
||||
Context: ts.config.Get(fmt.Sprintf("report.%s.context", report)) == "1",
|
||||
}
|
||||
}
|
||||
|
||||
return reports
|
||||
}
|
||||
|
||||
func extractReports(config string) []string {
|
||||
re := regexp.MustCompile(`report\.([^.]+)\.[^.]+`)
|
||||
matches := re.FindAllStringSubmatch(config, -1)
|
||||
uniques := make(map[string]struct{})
|
||||
for _, match := range matches {
|
||||
uniques[match[1]] = struct{}{}
|
||||
}
|
||||
|
||||
var reports []string
|
||||
for part := range uniques {
|
||||
reports = append(reports, part)
|
||||
}
|
||||
|
||||
slices.Sort(reports)
|
||||
return reports
|
||||
}
|
||||
|
||||
func (ts *TaskwarriorInterop) extractContexts() Contexts {
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_context"}...)...)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
if ts.ctx.Err() == context.Canceled {
|
||||
return nil
|
||||
}
|
||||
slog.Error("Failed getting contexts", "error", err, "output", string(output))
|
||||
return nil
|
||||
}
|
||||
|
||||
activeContext := ts.config.Get("context")
|
||||
if activeContext == "" {
|
||||
activeContext = "none"
|
||||
}
|
||||
|
||||
contexts := make(Contexts)
|
||||
contexts["none"] = &Context{
|
||||
Name: "none",
|
||||
Active: activeContext == "none",
|
||||
ReadFilter: "",
|
||||
WriteFilter: "",
|
||||
}
|
||||
for _, context := range strings.Split(string(output), "\n") {
|
||||
if context == "" {
|
||||
continue
|
||||
}
|
||||
contexts[context] = &Context{
|
||||
Name: context,
|
||||
Active: activeContext == context,
|
||||
ReadFilter: ts.config.Get(fmt.Sprintf("context.%s.read", context)),
|
||||
WriteFilter: ts.config.Get(fmt.Sprintf("context.%s.write", context)),
|
||||
}
|
||||
}
|
||||
|
||||
return contexts
|
||||
}
|
||||
66
internal/taskwarrior/taskwarrior_test.go
Normal file
66
internal/taskwarrior/taskwarrior_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package taskwarrior
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TaskWarriorTestSetup(dir string) {
|
||||
// Create a taskrc file
|
||||
taskrc := fmt.Sprintf("%s/taskrc", dir)
|
||||
taskrcContents := fmt.Sprintf("data.location=%s\n", dir)
|
||||
os.WriteFile(taskrc, []byte(taskrcContents), 0644)
|
||||
}
|
||||
|
||||
func TestTaskSquire_GetContext(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fmt.Printf("dir: %s", dir)
|
||||
TaskWarriorTestSetup(dir)
|
||||
|
||||
type fields struct {
|
||||
configLocation string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
fields fields
|
||||
prep func()
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "Test without context",
|
||||
fields: fields{
|
||||
configLocation: fmt.Sprintf("%s/taskrc", dir),
|
||||
},
|
||||
prep: func() {},
|
||||
want: "none",
|
||||
},
|
||||
{
|
||||
name: "Test with context",
|
||||
fields: fields{
|
||||
configLocation: fmt.Sprintf("%s/taskrc", dir),
|
||||
},
|
||||
prep: func() {
|
||||
f, err := os.OpenFile(fmt.Sprintf("%s/taskrc", dir), os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to open file: %s", err)
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := f.Write([]byte("context=test\ncontext.test.read=+test\ncontext.test.write=+test")); err != nil {
|
||||
t.Error("Failed to write to file")
|
||||
}
|
||||
},
|
||||
want: "test",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tt.prep()
|
||||
ts := NewTaskSquire(context.Background(), tt.fields.configLocation)
|
||||
if got := ts.GetActiveContext(); got.Name != tt.want {
|
||||
t.Errorf("TaskSquire.GetContext() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
127
internal/taskwarrior/tree.go
Normal file
127
internal/taskwarrior/tree.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package taskwarrior
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// TaskNode represents a task in the tree structure
|
||||
type TaskNode struct {
|
||||
Task *Task
|
||||
Children []*TaskNode
|
||||
Parent *TaskNode
|
||||
Depth int
|
||||
}
|
||||
|
||||
// TaskTree manages the hierarchical task structure
|
||||
type TaskTree struct {
|
||||
Nodes map[string]*TaskNode // UUID -> TaskNode
|
||||
Roots []*TaskNode // Top-level tasks (no parent)
|
||||
FlatList []*TaskNode // Flattened tree in display order
|
||||
}
|
||||
|
||||
// BuildTaskTree constructs a tree from a flat list of tasks
|
||||
// Three-pass algorithm:
|
||||
// 1. Create all nodes
|
||||
// 2. Establish parent-child relationships
|
||||
// 3. Calculate depths and flatten tree
|
||||
func BuildTaskTree(tasks Tasks) *TaskTree {
|
||||
tree := &TaskTree{
|
||||
Nodes: make(map[string]*TaskNode),
|
||||
Roots: make([]*TaskNode, 0),
|
||||
FlatList: make([]*TaskNode, 0),
|
||||
}
|
||||
|
||||
// Pass 1: Create all nodes
|
||||
for _, task := range tasks {
|
||||
node := &TaskNode{
|
||||
Task: task,
|
||||
Children: make([]*TaskNode, 0),
|
||||
Parent: nil,
|
||||
Depth: 0,
|
||||
}
|
||||
tree.Nodes[task.Uuid] = node
|
||||
}
|
||||
|
||||
// Pass 2: Establish parent-child relationships
|
||||
// Iterate over original tasks slice to preserve order
|
||||
for _, task := range tasks {
|
||||
node := tree.Nodes[task.Uuid]
|
||||
parentUUID := getParentUUID(node.Task)
|
||||
if parentUUID == "" {
|
||||
// No parent, this is a root task
|
||||
tree.Roots = append(tree.Roots, node)
|
||||
} else {
|
||||
// Find parent node
|
||||
parentNode, exists := tree.Nodes[parentUUID]
|
||||
if !exists {
|
||||
// Orphaned task - missing parent
|
||||
slog.Warn("Task has missing parent",
|
||||
"task_uuid", node.Task.Uuid,
|
||||
"parent_uuid", parentUUID,
|
||||
"task_desc", node.Task.Description)
|
||||
// Treat as root (graceful degradation)
|
||||
tree.Roots = append(tree.Roots, node)
|
||||
} else {
|
||||
// Establish relationship
|
||||
node.Parent = parentNode
|
||||
parentNode.Children = append(parentNode.Children, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3: Calculate depths and flatten tree
|
||||
for _, root := range tree.Roots {
|
||||
flattenNode(root, 0, &tree.FlatList)
|
||||
}
|
||||
|
||||
return tree
|
||||
}
|
||||
|
||||
// getParentUUID extracts the parent UUID from a task's UDAs
|
||||
func getParentUUID(task *Task) string {
|
||||
if task.Udas == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
parentVal, exists := task.Udas["parenttask"]
|
||||
if !exists {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Parent UDA is stored as a string
|
||||
if parentStr, ok := parentVal.(string); ok {
|
||||
return parentStr
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// flattenNode recursively flattens the tree in depth-first order
|
||||
func flattenNode(node *TaskNode, depth int, flatList *[]*TaskNode) {
|
||||
node.Depth = depth
|
||||
*flatList = append(*flatList, node)
|
||||
|
||||
// Recursively flatten children
|
||||
for _, child := range node.Children {
|
||||
flattenNode(child, depth+1, flatList)
|
||||
}
|
||||
}
|
||||
|
||||
// GetChildrenStatus returns completed/total counts for a parent task
|
||||
func (tn *TaskNode) GetChildrenStatus() (completed int, total int) {
|
||||
total = len(tn.Children)
|
||||
completed = 0
|
||||
|
||||
for _, child := range tn.Children {
|
||||
if child.Task.Status == "completed" {
|
||||
completed++
|
||||
}
|
||||
}
|
||||
|
||||
return completed, total
|
||||
}
|
||||
|
||||
// HasChildren returns true if the node has any children
|
||||
func (tn *TaskNode) HasChildren() bool {
|
||||
return len(tn.Children) > 0
|
||||
}
|
||||
345
internal/taskwarrior/tree_test.go
Normal file
345
internal/taskwarrior/tree_test.go
Normal file
@@ -0,0 +1,345 @@
|
||||
package taskwarrior
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildTaskTree_EmptyList(t *testing.T) {
|
||||
tasks := Tasks{}
|
||||
tree := BuildTaskTree(tasks)
|
||||
|
||||
if tree == nil {
|
||||
t.Fatal("Expected tree to be non-nil")
|
||||
}
|
||||
|
||||
if len(tree.Nodes) != 0 {
|
||||
t.Errorf("Expected 0 nodes, got %d", len(tree.Nodes))
|
||||
}
|
||||
|
||||
if len(tree.Roots) != 0 {
|
||||
t.Errorf("Expected 0 roots, got %d", len(tree.Roots))
|
||||
}
|
||||
|
||||
if len(tree.FlatList) != 0 {
|
||||
t.Errorf("Expected 0 items in flat list, got %d", len(tree.FlatList))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTaskTree_NoParents(t *testing.T) {
|
||||
tasks := Tasks{
|
||||
{Uuid: "task1", Description: "Task 1", Status: "pending"},
|
||||
{Uuid: "task2", Description: "Task 2", Status: "pending"},
|
||||
{Uuid: "task3", Description: "Task 3", Status: "completed"},
|
||||
}
|
||||
|
||||
tree := BuildTaskTree(tasks)
|
||||
|
||||
if len(tree.Nodes) != 3 {
|
||||
t.Errorf("Expected 3 nodes, got %d", len(tree.Nodes))
|
||||
}
|
||||
|
||||
if len(tree.Roots) != 3 {
|
||||
t.Errorf("Expected 3 roots (all tasks have no parent), got %d", len(tree.Roots))
|
||||
}
|
||||
|
||||
if len(tree.FlatList) != 3 {
|
||||
t.Errorf("Expected 3 items in flat list, got %d", len(tree.FlatList))
|
||||
}
|
||||
|
||||
// All tasks should have depth 0
|
||||
for i, node := range tree.FlatList {
|
||||
if node.Depth != 0 {
|
||||
t.Errorf("Task %d expected depth 0, got %d", i, node.Depth)
|
||||
}
|
||||
if node.HasChildren() {
|
||||
t.Errorf("Task %d should not have children", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTaskTree_SimpleParentChild(t *testing.T) {
|
||||
tasks := Tasks{
|
||||
{Uuid: "parent1", Description: "Parent Task", Status: "pending"},
|
||||
{
|
||||
Uuid: "child1",
|
||||
Description: "Child Task 1",
|
||||
Status: "pending",
|
||||
Udas: map[string]any{"parenttask": "parent1"},
|
||||
},
|
||||
{
|
||||
Uuid: "child2",
|
||||
Description: "Child Task 2",
|
||||
Status: "completed",
|
||||
Udas: map[string]any{"parenttask": "parent1"},
|
||||
},
|
||||
}
|
||||
|
||||
tree := BuildTaskTree(tasks)
|
||||
|
||||
if len(tree.Nodes) != 3 {
|
||||
t.Fatalf("Expected 3 nodes, got %d", len(tree.Nodes))
|
||||
}
|
||||
|
||||
if len(tree.Roots) != 1 {
|
||||
t.Fatalf("Expected 1 root, got %d", len(tree.Roots))
|
||||
}
|
||||
|
||||
// Check root is the parent
|
||||
root := tree.Roots[0]
|
||||
if root.Task.Uuid != "parent1" {
|
||||
t.Errorf("Expected root to be parent1, got %s", root.Task.Uuid)
|
||||
}
|
||||
|
||||
// Check parent has 2 children
|
||||
if len(root.Children) != 2 {
|
||||
t.Fatalf("Expected parent to have 2 children, got %d", len(root.Children))
|
||||
}
|
||||
|
||||
// Check children status
|
||||
completed, total := root.GetChildrenStatus()
|
||||
if total != 2 {
|
||||
t.Errorf("Expected total children = 2, got %d", total)
|
||||
}
|
||||
if completed != 1 {
|
||||
t.Errorf("Expected completed children = 1, got %d", completed)
|
||||
}
|
||||
|
||||
// Check flat list order (parent first, then children)
|
||||
if len(tree.FlatList) != 3 {
|
||||
t.Fatalf("Expected 3 items in flat list, got %d", len(tree.FlatList))
|
||||
}
|
||||
|
||||
if tree.FlatList[0].Task.Uuid != "parent1" {
|
||||
t.Errorf("Expected first item to be parent, got %s", tree.FlatList[0].Task.Uuid)
|
||||
}
|
||||
|
||||
if tree.FlatList[0].Depth != 0 {
|
||||
t.Errorf("Expected parent depth = 0, got %d", tree.FlatList[0].Depth)
|
||||
}
|
||||
|
||||
// Children should be at depth 1
|
||||
for i := 1; i < 3; i++ {
|
||||
if tree.FlatList[i].Depth != 1 {
|
||||
t.Errorf("Expected child %d depth = 1, got %d", i, tree.FlatList[i].Depth)
|
||||
}
|
||||
if tree.FlatList[i].Parent == nil {
|
||||
t.Errorf("Child %d should have a parent", i)
|
||||
} else if tree.FlatList[i].Parent.Task.Uuid != "parent1" {
|
||||
t.Errorf("Child %d parent should be parent1, got %s", i, tree.FlatList[i].Parent.Task.Uuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTaskTree_MultiLevel(t *testing.T) {
|
||||
tasks := Tasks{
|
||||
{Uuid: "grandparent", Description: "Grandparent", Status: "pending"},
|
||||
{
|
||||
Uuid: "parent1",
|
||||
Description: "Parent 1",
|
||||
Status: "pending",
|
||||
Udas: map[string]any{"parenttask": "grandparent"},
|
||||
},
|
||||
{
|
||||
Uuid: "parent2",
|
||||
Description: "Parent 2",
|
||||
Status: "pending",
|
||||
Udas: map[string]any{"parenttask": "grandparent"},
|
||||
},
|
||||
{
|
||||
Uuid: "child1",
|
||||
Description: "Child 1",
|
||||
Status: "pending",
|
||||
Udas: map[string]any{"parenttask": "parent1"},
|
||||
},
|
||||
{
|
||||
Uuid: "grandchild1",
|
||||
Description: "Grandchild 1",
|
||||
Status: "completed",
|
||||
Udas: map[string]any{"parenttask": "child1"},
|
||||
},
|
||||
}
|
||||
|
||||
tree := BuildTaskTree(tasks)
|
||||
|
||||
if len(tree.Nodes) != 5 {
|
||||
t.Fatalf("Expected 5 nodes, got %d", len(tree.Nodes))
|
||||
}
|
||||
|
||||
if len(tree.Roots) != 1 {
|
||||
t.Fatalf("Expected 1 root, got %d", len(tree.Roots))
|
||||
}
|
||||
|
||||
// Find nodes by UUID
|
||||
grandparentNode := tree.Nodes["grandparent"]
|
||||
parent1Node := tree.Nodes["parent1"]
|
||||
child1Node := tree.Nodes["child1"]
|
||||
grandchildNode := tree.Nodes["grandchild1"]
|
||||
|
||||
// Check depths
|
||||
if grandparentNode.Depth != 0 {
|
||||
t.Errorf("Expected grandparent depth = 0, got %d", grandparentNode.Depth)
|
||||
}
|
||||
if parent1Node.Depth != 1 {
|
||||
t.Errorf("Expected parent1 depth = 1, got %d", parent1Node.Depth)
|
||||
}
|
||||
if child1Node.Depth != 2 {
|
||||
t.Errorf("Expected child1 depth = 2, got %d", child1Node.Depth)
|
||||
}
|
||||
if grandchildNode.Depth != 3 {
|
||||
t.Errorf("Expected grandchild depth = 3, got %d", grandchildNode.Depth)
|
||||
}
|
||||
|
||||
// Check parent-child relationships
|
||||
if len(grandparentNode.Children) != 2 {
|
||||
t.Errorf("Expected grandparent to have 2 children, got %d", len(grandparentNode.Children))
|
||||
}
|
||||
|
||||
if len(parent1Node.Children) != 1 {
|
||||
t.Errorf("Expected parent1 to have 1 child, got %d", len(parent1Node.Children))
|
||||
}
|
||||
|
||||
if len(child1Node.Children) != 1 {
|
||||
t.Errorf("Expected child1 to have 1 child, got %d", len(child1Node.Children))
|
||||
}
|
||||
|
||||
if grandchildNode.HasChildren() {
|
||||
t.Error("Expected grandchild to have no children")
|
||||
}
|
||||
|
||||
// Check flat list maintains tree order
|
||||
if len(tree.FlatList) != 5 {
|
||||
t.Fatalf("Expected 5 items in flat list, got %d", len(tree.FlatList))
|
||||
}
|
||||
|
||||
// Grandparent should be first
|
||||
if tree.FlatList[0].Task.Uuid != "grandparent" {
|
||||
t.Errorf("Expected first item to be grandparent, got %s", tree.FlatList[0].Task.Uuid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTaskTree_OrphanedTask(t *testing.T) {
|
||||
tasks := Tasks{
|
||||
{Uuid: "task1", Description: "Normal Task", Status: "pending"},
|
||||
{
|
||||
Uuid: "orphan",
|
||||
Description: "Orphaned Task",
|
||||
Status: "pending",
|
||||
Udas: map[string]any{"parenttask": "nonexistent"},
|
||||
},
|
||||
}
|
||||
|
||||
tree := BuildTaskTree(tasks)
|
||||
|
||||
if len(tree.Nodes) != 2 {
|
||||
t.Fatalf("Expected 2 nodes, got %d", len(tree.Nodes))
|
||||
}
|
||||
|
||||
// Orphaned task should be treated as root
|
||||
if len(tree.Roots) != 2 {
|
||||
t.Errorf("Expected 2 roots (orphan should be treated as root), got %d", len(tree.Roots))
|
||||
}
|
||||
|
||||
// Both should have depth 0
|
||||
for _, node := range tree.FlatList {
|
||||
if node.Depth != 0 {
|
||||
t.Errorf("Expected all tasks to have depth 0, got %d for %s", node.Depth, node.Task.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskNode_GetChildrenStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
children []*TaskNode
|
||||
wantComp int
|
||||
wantTotal int
|
||||
}{
|
||||
{
|
||||
name: "no children",
|
||||
children: []*TaskNode{},
|
||||
wantComp: 0,
|
||||
wantTotal: 0,
|
||||
},
|
||||
{
|
||||
name: "all pending",
|
||||
children: []*TaskNode{
|
||||
{Task: &Task{Status: "pending"}},
|
||||
{Task: &Task{Status: "pending"}},
|
||||
},
|
||||
wantComp: 0,
|
||||
wantTotal: 2,
|
||||
},
|
||||
{
|
||||
name: "all completed",
|
||||
children: []*TaskNode{
|
||||
{Task: &Task{Status: "completed"}},
|
||||
{Task: &Task{Status: "completed"}},
|
||||
{Task: &Task{Status: "completed"}},
|
||||
},
|
||||
wantComp: 3,
|
||||
wantTotal: 3,
|
||||
},
|
||||
{
|
||||
name: "mixed status",
|
||||
children: []*TaskNode{
|
||||
{Task: &Task{Status: "completed"}},
|
||||
{Task: &Task{Status: "pending"}},
|
||||
{Task: &Task{Status: "completed"}},
|
||||
{Task: &Task{Status: "pending"}},
|
||||
{Task: &Task{Status: "completed"}},
|
||||
},
|
||||
wantComp: 3,
|
||||
wantTotal: 5,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
node := &TaskNode{
|
||||
Task: &Task{},
|
||||
Children: tt.children,
|
||||
}
|
||||
|
||||
gotComp, gotTotal := node.GetChildrenStatus()
|
||||
|
||||
if gotComp != tt.wantComp {
|
||||
t.Errorf("GetChildrenStatus() completed = %d, want %d", gotComp, tt.wantComp)
|
||||
}
|
||||
if gotTotal != tt.wantTotal {
|
||||
t.Errorf("GetChildrenStatus() total = %d, want %d", gotTotal, tt.wantTotal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskNode_HasChildren(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
children []*TaskNode
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "no children",
|
||||
children: []*TaskNode{},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "has children",
|
||||
children: []*TaskNode{{Task: &Task{}}},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
node := &TaskNode{
|
||||
Task: &Task{},
|
||||
Children: tt.children,
|
||||
}
|
||||
|
||||
if got := node.HasChildren(); got != tt.want {
|
||||
t.Errorf("HasChildren() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
78
internal/timewarrior/config.go
Normal file
78
internal/timewarrior/config.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package timewarrior
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type TWConfig struct {
|
||||
config map[string]string
|
||||
}
|
||||
|
||||
var (
|
||||
defaultConfig = map[string]string{
|
||||
"uda.timesquire.default.tag": "",
|
||||
}
|
||||
)
|
||||
|
||||
func NewConfig(config []string) *TWConfig {
|
||||
cfg := parseConfig(config)
|
||||
|
||||
for key, value := range defaultConfig {
|
||||
if _, ok := cfg[key]; !ok {
|
||||
cfg[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return &TWConfig{
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *TWConfig) GetConfig() map[string]string {
|
||||
return tc.config
|
||||
}
|
||||
|
||||
func (tc *TWConfig) Get(key string) string {
|
||||
if _, ok := tc.config[key]; !ok {
|
||||
slog.Debug(fmt.Sprintf("Key not found in config: %s", key))
|
||||
return ""
|
||||
}
|
||||
|
||||
return tc.config[key]
|
||||
}
|
||||
|
||||
func parseConfig(config []string) map[string]string {
|
||||
configMap := make(map[string]string)
|
||||
|
||||
for _, line := range config {
|
||||
// Skip empty lines and comments
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Timewarrior config format: key = value or key: value
|
||||
var key, value string
|
||||
if strings.Contains(line, "=") {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
key = strings.TrimSpace(parts[0])
|
||||
value = strings.TrimSpace(parts[1])
|
||||
}
|
||||
} else if strings.Contains(line, ":") {
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
key = strings.TrimSpace(parts[0])
|
||||
value = strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
if key != "" {
|
||||
configMap[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return configMap
|
||||
}
|
||||
323
internal/timewarrior/models.go
Normal file
323
internal/timewarrior/models.go
Normal file
@@ -0,0 +1,323 @@
|
||||
package timewarrior
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
dtformat = "20060102T150405Z"
|
||||
)
|
||||
|
||||
type Intervals []*Interval
|
||||
|
||||
type Interval struct {
|
||||
ID int `json:"-"`
|
||||
Start string `json:"start,omitempty"`
|
||||
End string `json:"end,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
IsGap bool `json:"-"` // True if this represents an untracked time gap
|
||||
}
|
||||
|
||||
func NewInterval() *Interval {
|
||||
return &Interval{
|
||||
Tags: make([]string, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// NewGapInterval creates a new gap interval representing untracked time.
|
||||
// start and end are the times between which the gap occurred.
|
||||
func NewGapInterval(start, end time.Time) *Interval {
|
||||
return &Interval{
|
||||
ID: -1, // Gap intervals have no real ID
|
||||
Start: start.UTC().Format(dtformat),
|
||||
End: end.UTC().Format(dtformat),
|
||||
Tags: make([]string, 0),
|
||||
IsGap: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Interval) GetString(field string) string {
|
||||
// Special handling for gap intervals
|
||||
if i.IsGap {
|
||||
switch field {
|
||||
case "duration":
|
||||
return i.GetDuration()
|
||||
case "gap_display":
|
||||
return fmt.Sprintf("--- Untracked: %s ---", i.GetDuration())
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
switch field {
|
||||
case "id":
|
||||
return strconv.Itoa(i.ID)
|
||||
|
||||
case "start":
|
||||
return formatDate(i.Start, "formatted")
|
||||
|
||||
case "start_time":
|
||||
return formatDate(i.Start, "time")
|
||||
|
||||
case "end":
|
||||
if i.End == "" {
|
||||
return "now"
|
||||
}
|
||||
return formatDate(i.End, "formatted")
|
||||
|
||||
case "end_time":
|
||||
if i.End == "" {
|
||||
return "now"
|
||||
}
|
||||
return formatDate(i.End, "time")
|
||||
|
||||
case "weekday":
|
||||
return formatDate(i.Start, "weekday")
|
||||
|
||||
case "tags":
|
||||
if len(i.Tags) == 0 {
|
||||
return ""
|
||||
}
|
||||
// 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()
|
||||
|
||||
case "active":
|
||||
if i.End == "" {
|
||||
return "●"
|
||||
}
|
||||
return ""
|
||||
|
||||
default:
|
||||
slog.Error("Field not implemented", "field", field)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Interval) GetDuration() string {
|
||||
start, err := time.Parse(dtformat, i.Start)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse start time", "error", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
var end time.Time
|
||||
if i.End == "" {
|
||||
end = time.Now()
|
||||
} else {
|
||||
end, err = time.Parse(dtformat, i.End)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse end time", "error", err)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
duration := end.Sub(start)
|
||||
return formatDuration(duration)
|
||||
}
|
||||
|
||||
func (i *Interval) GetStartTime() time.Time {
|
||||
dt, err := time.Parse(dtformat, i.Start)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse time", "error", err)
|
||||
return time.Time{}
|
||||
}
|
||||
return dt
|
||||
}
|
||||
|
||||
func (i *Interval) GetEndTime() time.Time {
|
||||
if i.End == "" {
|
||||
return time.Now()
|
||||
}
|
||||
dt, err := time.Parse(dtformat, i.End)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse time", "error", err)
|
||||
return time.Time{}
|
||||
}
|
||||
return dt
|
||||
}
|
||||
|
||||
func (i *Interval) HasTag(tag string) bool {
|
||||
for _, t := range i.Tags {
|
||||
if t == tag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (i *Interval) AddTag(tag string) {
|
||||
if !i.HasTag(tag) {
|
||||
i.Tags = append(i.Tags, tag)
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Interval) RemoveTag(tag string) {
|
||||
for idx, t := range i.Tags {
|
||||
if t == tag {
|
||||
i.Tags = append(i.Tags[:idx], i.Tags[idx+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Interval) IsActive() bool {
|
||||
return i.End == ""
|
||||
}
|
||||
|
||||
func formatDate(date string, format string) string {
|
||||
if date == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
dt, err := time.Parse(dtformat, date)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse time", "error", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
dt = dt.Local()
|
||||
|
||||
switch format {
|
||||
case "formatted", "":
|
||||
return dt.Format("2006-01-02 15:04")
|
||||
case "time":
|
||||
return dt.Format("15:04")
|
||||
case "date":
|
||||
return dt.Format("2006-01-02")
|
||||
case "weekday":
|
||||
return dt.Format("Mon")
|
||||
case "iso":
|
||||
return dt.Format("2006-01-02T150405Z")
|
||||
case "epoch":
|
||||
return strconv.FormatInt(dt.Unix(), 10)
|
||||
case "age":
|
||||
return parseDurationVague(time.Since(dt))
|
||||
case "relative":
|
||||
return parseDurationVague(time.Until(dt))
|
||||
default:
|
||||
slog.Error("Date format not implemented", "format", format)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func formatDuration(d time.Duration) string {
|
||||
hours := int(d.Hours())
|
||||
minutes := int(d.Minutes()) % 60
|
||||
seconds := int(d.Seconds()) % 60
|
||||
|
||||
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds)
|
||||
}
|
||||
|
||||
func parseDurationVague(d time.Duration) string {
|
||||
dur := d.Round(time.Second).Abs()
|
||||
days := dur.Hours() / 24
|
||||
|
||||
var formatted string
|
||||
if dur >= time.Hour*24*365 {
|
||||
formatted = fmt.Sprintf("%.1fy", days/365)
|
||||
} else if dur >= time.Hour*24*90 {
|
||||
formatted = strconv.Itoa(int(math.Round(days/30))) + "mo"
|
||||
} else if dur >= time.Hour*24*7 {
|
||||
formatted = strconv.Itoa(int(math.Round(days/7))) + "w"
|
||||
} else if dur >= time.Hour*24 {
|
||||
formatted = strconv.Itoa(int(days)) + "d"
|
||||
} else if dur >= time.Hour {
|
||||
formatted = strconv.Itoa(int(dur.Round(time.Hour).Hours())) + "h"
|
||||
} else if dur >= time.Minute {
|
||||
formatted = strconv.Itoa(int(dur.Round(time.Minute).Minutes())) + "min"
|
||||
} else if dur >= time.Second {
|
||||
formatted = strconv.Itoa(int(dur.Round(time.Second).Seconds())) + "s"
|
||||
}
|
||||
|
||||
if d < 0 {
|
||||
formatted = "-" + formatted
|
||||
}
|
||||
|
||||
return formatted
|
||||
}
|
||||
|
||||
var (
|
||||
dateFormats = []string{
|
||||
"2006-01-02",
|
||||
"2006-01-02T15:04",
|
||||
"20060102T150405Z",
|
||||
}
|
||||
|
||||
specialDateFormats = []string{
|
||||
"",
|
||||
"now",
|
||||
"today",
|
||||
"yesterday",
|
||||
"tomorrow",
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
"sunday",
|
||||
"mon",
|
||||
"tue",
|
||||
"wed",
|
||||
"thu",
|
||||
"fri",
|
||||
"sat",
|
||||
"sun",
|
||||
}
|
||||
)
|
||||
|
||||
func ValidateDate(s string) error {
|
||||
for _, f := range dateFormats {
|
||||
if _, err := time.Parse(f, s); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
for _, f := range specialDateFormats {
|
||||
if s == f {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("invalid date")
|
||||
}
|
||||
|
||||
func ValidateDuration(s string) error {
|
||||
// TODO: implement proper duration validation
|
||||
// Should accept formats like: 1h, 30m, 1h30m, etc.
|
||||
return nil
|
||||
}
|
||||
|
||||
// Summary represents time tracking summary data
|
||||
type Summary struct {
|
||||
Range string
|
||||
TotalTime time.Duration
|
||||
ByTag map[string]time.Duration
|
||||
}
|
||||
|
||||
func (s *Summary) GetTotalString() string {
|
||||
return formatDuration(s.TotalTime)
|
||||
}
|
||||
|
||||
func (s *Summary) GetTagTime(tag string) string {
|
||||
if duration, ok := s.ByTag[tag]; ok {
|
||||
return formatDuration(duration)
|
||||
}
|
||||
return "0:00"
|
||||
}
|
||||
54
internal/timewarrior/tags.go
Normal file
54
internal/timewarrior/tags.go
Normal file
@@ -0,0 +1,54 @@
|
||||
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)
|
||||
case tag == "track":
|
||||
// Skip the "track" tag - it's internal metadata
|
||||
continue
|
||||
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 ""
|
||||
}
|
||||
411
internal/timewarrior/timewarrior.go
Normal file
411
internal/timewarrior/timewarrior.go
Normal file
@@ -0,0 +1,411 @@
|
||||
// TODO: error handling
|
||||
// TODO: split combinedOutput and handle stderr differently
|
||||
package timewarrior
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
twBinary = "timew"
|
||||
)
|
||||
|
||||
type TimeWarrior interface {
|
||||
GetConfig() *TWConfig
|
||||
|
||||
GetTags() []string
|
||||
GetTagCombinations() []string
|
||||
|
||||
GetIntervals(filter ...string) Intervals
|
||||
StartTracking(tags []string) error
|
||||
StopTracking() error
|
||||
ContinueTracking() error
|
||||
ContinueInterval(id int) error
|
||||
CancelTracking() error
|
||||
DeleteInterval(id int) error
|
||||
FillInterval(id int) error
|
||||
JoinInterval(id int) error
|
||||
ModifyInterval(interval *Interval, adjust bool) error
|
||||
GetSummary(filter ...string) string
|
||||
GetActive() *Interval
|
||||
|
||||
Undo()
|
||||
}
|
||||
|
||||
type TimewarriorInterop struct {
|
||||
configLocation string
|
||||
defaultArgs []string
|
||||
config *TWConfig
|
||||
ctx context.Context
|
||||
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
func NewTimewarriorInterop(ctx context.Context, configLocation string) *TimewarriorInterop {
|
||||
if _, err := exec.LookPath(twBinary); err != nil {
|
||||
slog.Error("Timewarrior not found")
|
||||
return nil
|
||||
}
|
||||
|
||||
ts := &TimewarriorInterop{
|
||||
configLocation: configLocation,
|
||||
defaultArgs: []string{},
|
||||
ctx: ctx,
|
||||
mutex: sync.Mutex{},
|
||||
}
|
||||
ts.config = ts.extractConfig()
|
||||
|
||||
return ts
|
||||
}
|
||||
|
||||
func (ts *TimewarriorInterop) GetConfig() *TWConfig {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
return ts.config
|
||||
}
|
||||
|
||||
func (ts *TimewarriorInterop) GetTags() []string {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"tags"}...)...)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("Failed getting tags", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
tags := make([]string, 0)
|
||||
lines := strings.Split(string(output), "\n")
|
||||
|
||||
// Skip header lines and parse tag names
|
||||
for i, line := range lines {
|
||||
if i < 3 || line == "" {
|
||||
continue
|
||||
}
|
||||
// Tags are space-separated, first column is the tag name
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) > 0 {
|
||||
tags = append(tags, fields[0])
|
||||
}
|
||||
}
|
||||
|
||||
slices.Sort(tags)
|
||||
return tags
|
||||
}
|
||||
|
||||
// GetTagCombinations returns unique tag combinations from intervals,
|
||||
// ordered newest first (most recent intervals' tags appear first).
|
||||
// Returns formatted strings like "dev client-work meeting".
|
||||
func (ts *TimewarriorInterop) GetTagCombinations() []string {
|
||||
intervals := ts.GetIntervals() // Already sorted newest first
|
||||
|
||||
// Track unique combinations while preserving order
|
||||
seen := make(map[string]bool)
|
||||
var combinations []string
|
||||
|
||||
for _, interval := range intervals {
|
||||
if len(interval.Tags) == 0 {
|
||||
continue // Skip intervals with no tags
|
||||
}
|
||||
|
||||
// Format tags (handles spaces with quotes)
|
||||
combo := formatTagsForCombination(interval.Tags)
|
||||
|
||||
if !seen[combo] {
|
||||
seen[combo] = true
|
||||
combinations = append(combinations, combo)
|
||||
}
|
||||
}
|
||||
|
||||
return combinations
|
||||
}
|
||||
|
||||
// formatTagsForCombination formats tags consistently for display
|
||||
func formatTagsForCombination(tags []string) string {
|
||||
var formatted []string
|
||||
for _, t := range tags {
|
||||
if strings.Contains(t, " ") {
|
||||
formatted = append(formatted, "\""+t+"\"")
|
||||
} else {
|
||||
formatted = append(formatted, t)
|
||||
}
|
||||
}
|
||||
return strings.Join(formatted, " ")
|
||||
}
|
||||
|
||||
// getIntervalsUnlocked fetches intervals without acquiring mutex (for internal use only).
|
||||
// Caller must hold ts.mutex.
|
||||
func (ts *TimewarriorInterop) getIntervalsUnlocked(filter ...string) Intervals {
|
||||
args := append(ts.defaultArgs, "export")
|
||||
if filter != nil {
|
||||
args = append(args, filter...)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
if ts.ctx.Err() == context.Canceled {
|
||||
return nil
|
||||
}
|
||||
slog.Error("Failed getting intervals", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
intervals := make(Intervals, 0)
|
||||
err = json.Unmarshal(output, &intervals)
|
||||
if err != nil {
|
||||
slog.Error("Failed unmarshalling intervals", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reverse the intervals to show newest first
|
||||
slices.Reverse(intervals)
|
||||
|
||||
// Assign IDs based on new order (newest is @1)
|
||||
for i := range intervals {
|
||||
intervals[i].ID = i + 1
|
||||
}
|
||||
|
||||
return intervals
|
||||
}
|
||||
|
||||
// GetIntervals fetches intervals with the given filter, acquiring mutex lock.
|
||||
func (ts *TimewarriorInterop) GetIntervals(filter ...string) Intervals {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
return ts.getIntervalsUnlocked(filter...)
|
||||
}
|
||||
|
||||
func (ts *TimewarriorInterop) StartTracking(tags []string) error {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
if len(tags) == 0 {
|
||||
return fmt.Errorf("at least one tag is required")
|
||||
}
|
||||
|
||||
args := append(ts.defaultArgs, "start")
|
||||
args = append(args, tags...)
|
||||
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, args...)
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Error("Failed starting tracking", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ts *TimewarriorInterop) StopTracking() error {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "stop")...)
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Error("Failed stopping tracking", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ts *TimewarriorInterop) ContinueTracking() error {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "continue")...)
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Error("Failed continuing tracking", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ts *TimewarriorInterop) ContinueInterval(id int) error {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"continue", fmt.Sprintf("@%d", id)}...)...)
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Error("Failed continuing interval", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ts *TimewarriorInterop) CancelTracking() error {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "cancel")...)
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Error("Failed canceling tracking", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ts *TimewarriorInterop) DeleteInterval(id int) error {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"delete", fmt.Sprintf("@%d", id)}...)...)
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Error("Failed deleting interval", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ts *TimewarriorInterop) FillInterval(id int) error {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"fill", fmt.Sprintf("@%d", id)}...)...)
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Error("Failed filling interval", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ts *TimewarriorInterop) JoinInterval(id int) error {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
// Join the current interval with the previous one
|
||||
// The previous interval has id+1 (since intervals are ordered newest first)
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"join", fmt.Sprintf("@%d", id+1), fmt.Sprintf("@%d", id)}...)...)
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Error("Failed joining interval", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ts *TimewarriorInterop) ModifyInterval(interval *Interval, adjust bool) error {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
// Export the modified interval
|
||||
intervals, err := json.Marshal(Intervals{interval})
|
||||
if err != nil {
|
||||
slog.Error("Failed marshalling interval", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Build import command with optional :adjust hint
|
||||
args := append(ts.defaultArgs, "import")
|
||||
if adjust {
|
||||
args = append(args, ":adjust")
|
||||
}
|
||||
|
||||
// Import the modified interval
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, args...)
|
||||
cmd.Stdin = bytes.NewBuffer(intervals)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("Failed modifying interval", "error", err, "output", string(out))
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ts *TimewarriorInterop) GetSummary(filter ...string) string {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
args := append(ts.defaultArgs, "summary")
|
||||
if filter != nil {
|
||||
args = append(args, filter...)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("Failed getting summary", "error", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
return string(output)
|
||||
}
|
||||
|
||||
func (ts *TimewarriorInterop) GetActive() *Interval {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"get", "dom.active"}...)...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil || string(output) == "0\n" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get the active interval using unlocked version (we already hold the mutex)
|
||||
intervals := ts.getIntervalsUnlocked()
|
||||
for _, interval := range intervals {
|
||||
if interval.End == "" {
|
||||
return interval
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ts *TimewarriorInterop) Undo() {
|
||||
ts.mutex.Lock()
|
||||
defer ts.mutex.Unlock()
|
||||
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"undo"}...)...)
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
slog.Error("Failed undoing", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (ts *TimewarriorInterop) extractConfig() *TWConfig {
|
||||
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"show"}...)...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("Failed getting config", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return NewConfig(strings.Split(string(output), "\n"))
|
||||
}
|
||||
|
||||
func extractTags(config string) []string {
|
||||
re := regexp.MustCompile(`tag\.([^.]+)\.[^.]+`)
|
||||
matches := re.FindAllStringSubmatch(config, -1)
|
||||
uniques := make(map[string]struct{})
|
||||
for _, match := range matches {
|
||||
uniques[match[1]] = struct{}{}
|
||||
}
|
||||
|
||||
var tags []string
|
||||
for tag := range uniques {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
|
||||
slices.Sort(tags)
|
||||
return tags
|
||||
}
|
||||
Reference in New Issue
Block a user