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)
|
||||
}
|
||||
Reference in New Issue
Block a user