Tear down everything

Fix config
This commit is contained in:
Martin Pander
2026-02-26 20:00:56 +01:00
parent 6b1418fc71
commit 418bcd96a8
50 changed files with 256 additions and 8377 deletions

71
internal/common/common.go Normal file
View 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()
}

View 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
View 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
View 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
View 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
View 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
View 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)
}