diff --git a/.gitignore b/.gitignore index e43b0f9..9320e1e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ .DS_Store +app.log +test/taskchampion.sqlite3 diff --git a/common/task.go b/common/task.go new file mode 100644 index 0000000..8c38d4b --- /dev/null +++ b/common/task.go @@ -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:"parent,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 "parent": + 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(fmt.Sprintf("Field not implemented: %s", 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:", 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(fmt.Sprintf("Date format not implemented: %s", 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) +} diff --git a/main.go b/main.go index e0d8faa..adabf45 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,8 @@ package main import ( "context" "fmt" + "log" + "log/slog" "os" "tasksquire/common" @@ -17,6 +19,18 @@ func main() { ctx := context.Background() common := common.NewCommon(ctx, ts) + file, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + log.Fatalf("failed to open log file: %v", err) + } + defer file.Close() + + // Create a new slog handler for the file + handler := slog.NewTextHandler(file, &slog.HandlerOptions{}) + + // Set the default logger to use the file handler + slog.SetDefault(slog.New(handler)) + // form := huh.NewForm( // huh.NewGroup( // huh.NewSelect[string](). diff --git a/pages/report.go b/pages/report.go index bdeee2b..e618c3f 100644 --- a/pages/report.go +++ b/pages/report.go @@ -1,6 +1,8 @@ +// TODO: update table every second (to show correct relative time) package pages import ( + "strings" "tasksquire/common" "tasksquire/taskwarrior" @@ -161,7 +163,7 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) if p.tasks != nil && len(p.tasks) > 0 { - p.selectedTask = (*taskwarrior.Task)(p.tasks[p.taskTable.Cursor()]) + p.selectedTask = p.tasks[p.taskTable.Cursor()] } else { p.selectedTask = nil } @@ -185,6 +187,7 @@ func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) { columnSizes := make([]int, nCols) fullRows := make([]table.Row, len(tasks)) rows := make([]table.Row, len(tasks)) + descIndex := -1 for i, task := range tasks { if p.selectedTask != nil && task.Uuid == p.selectedTask.Uuid { @@ -193,6 +196,9 @@ func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) { row := table.Row{} for i, col := range p.activeReport.Columns { + if strings.Contains(col, "description") { + descIndex = i + } field := task.GetString(col) columnSizes[i] = max(columnSizes[i], len(field)) row = append(row, field) @@ -211,12 +217,24 @@ func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) { rows[i] = row } + combinedSize := 0 for i, label := range p.activeReport.Labels { if columnSizes[i] == 0 { continue } - columns = append(columns, table.Column{Title: label, Width: max(columnSizes[i], len(label))}) + width := max(columnSizes[i], len(label)) + columns = append(columns, table.Column{Title: label, Width: width}) + + if i == descIndex { + descIndex = len(columns) - 1 + continue + } + combinedSize += width + } + + if descIndex >= 0 { + columns[descIndex].Width = p.taskTable.Width() - combinedSize - 14 } if selected == 0 { diff --git a/pages/taskEditor.go b/pages/taskEditor.go index c355777..b8ae634 100644 --- a/pages/taskEditor.go +++ b/pages/taskEditor.go @@ -105,6 +105,7 @@ func NewTaskEditorPage(common *common.Common, task taskwarrior.Task) *TaskEditor huh.NewMultiSelect[string](). Options(huh.NewOptions(tagOptions...)...). + // Key("tags"). Title("Tags"). Value(&p.task.Tags), @@ -245,6 +246,8 @@ func (p *TaskEditorPage) updateTasksCmd() tea.Msg { if p.additionalProject != "" { p.task.Project = p.additionalProject } + // tags := p.form.Get("tags").([]string) + // p.task.Tags = tags p.common.TW.ImportTask(&p.task) return UpdatedTasksMsg{} } diff --git a/taskwarrior/models.go b/taskwarrior/models.go index ce1c6d7..e5fda31 100644 --- a/taskwarrior/models.go +++ b/taskwarrior/models.go @@ -19,26 +19,26 @@ func (a Annotation) String() string { } 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 + 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:"parent,omitempty"` - Due string `json:"due"` - Wait string `json:"wait"` - Scheduled string `json:"scheduled"` - Until string `json:"until"` + 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"` + Recur string `json:"recur,omitempty"` Annotations []Annotation `json:"annotations,omitempty"` } diff --git a/taskwarrior/taskwarrior.go b/taskwarrior/taskwarrior.go index 43ad999..f3395c6 100644 --- a/taskwarrior/taskwarrior.go +++ b/taskwarrior/taskwarrior.go @@ -368,6 +368,7 @@ func (ts *TaskSquire) ImportTask(task *Task) { defer ts.mutex.Unlock() tasks, err := json.Marshal(Tasks{task}) + if err != nil { slog.Error("Failed marshalling task:", err) }