Integrate timewarrior
This commit is contained in:
71
GEMINI.md
Normal file
71
GEMINI.md
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
# Tasksquire
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Tasksquire is a Terminal User Interface (TUI) for [Taskwarrior](https://taskwarrior.org/), built using Go and the [Charm](https://charm.sh/) ecosystem (Bubble Tea, Lip Gloss, Huh). It provides a visual and interactive way to manage your tasks, contexts, and reports directly from the terminal.
|
||||||
|
|
||||||
|
The application functions as a wrapper around the `task` command-line tool, parsing its output (JSON, config) and executing commands to read and modify task data.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The project follows the standard [Bubble Tea](https://github.com/charmbracelet/bubbletea) Model-View-Update (MVU) architecture.
|
||||||
|
|
||||||
|
### Key Directories & Files
|
||||||
|
|
||||||
|
* **`main.go`**: The entry point of the application. It initializes the `TaskSquire` wrapper, sets up logging, and starts the Bubble Tea program with the `MainPage`.
|
||||||
|
* **`taskwarrior/`**: Contains the logic for interacting with the Taskwarrior CLI.
|
||||||
|
* `taskwarrior.go`: The core wrapper (`TaskSquire` struct) that executes `task` commands (`export`, `add`, `modify`, etc.) and parses results.
|
||||||
|
* `models.go`: Defines the Go structs matching Taskwarrior's data model (Tasks, Reports, Config).
|
||||||
|
* **`pages/`**: Contains the different views of the application.
|
||||||
|
* `main.go`: The top-level component (`MainPage`) that manages routing/switching between different pages.
|
||||||
|
* `report.go`: Displays lists of tasks (Taskwarrior reports).
|
||||||
|
* `taskEditor.go`: UI for creating or editing tasks.
|
||||||
|
* **`common/`**: Shared utilities, global state, and data structures used across the application.
|
||||||
|
* **`components/`**: Reusable UI components (e.g., inputs, tables).
|
||||||
|
* **`timewarrior/`**: Contains logic for integration with Timewarrior (likely in progress or planned).
|
||||||
|
|
||||||
|
## Building and Running
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
* **Go**: Version 1.22 or higher.
|
||||||
|
* **Taskwarrior**: The `task` binary must be installed and available in your system's `PATH`.
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
To run the application directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
To build a binary:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o tasksquire main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nix Support
|
||||||
|
|
||||||
|
This project includes a `flake.nix` for users of the Nix package manager. You can enter a development shell with all dependencies (Go, tools) by running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix develop
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Tasksquire respects your existing Taskwarrior configuration (`.taskrc`). It looks for the configuration file in the following order:
|
||||||
|
|
||||||
|
1. `TASKRC` environment variable.
|
||||||
|
2. `$HOME/.taskrc`
|
||||||
|
3. `$HOME/.config/task/taskrc`
|
||||||
|
|
||||||
|
Logging is written to `app.log` in the current working directory.
|
||||||
|
|
||||||
|
## Development Conventions
|
||||||
|
|
||||||
|
* **UI Framework**: Uses [Bubble Tea](https://github.com/charmbracelet/bubbletea) for the TUI loop.
|
||||||
|
* **Styling**: Uses [Lip Gloss](https://github.com/charmbracelet/lipgloss) for terminal styling.
|
||||||
|
* **Forms**: Uses [Huh](https://github.com/charmbracelet/huh) for form inputs.
|
||||||
|
* **Logging**: Uses `log/slog` for structured logging.
|
||||||
16
README.md
16
README.md
@ -1,16 +1,20 @@
|
|||||||
- [ ] Use JJ
|
# TODO
|
||||||
- [ ] Add tag manager
|
- [>] Add default tags
|
||||||
- [ ] Edit default tags
|
- Default tags should be defined in the config and always displayed in the tag picker
|
||||||
- Default tags should be defined in the config and always displayed in the tag picker
|
|
||||||
- [ ] Add project manager
|
- [ ] Add project manager
|
||||||
- [ ] Add projects that are always displayed in the project picker
|
- [ ] Add projects that are always displayed in the project picker
|
||||||
- Saved in config
|
- Saved in config
|
||||||
- [ ] Remove/archive projects
|
- [ ] Remove/archive projects
|
||||||
- [ ] Update to bubbletea 2.0
|
|
||||||
- [ ] Integrate timewarrior
|
- [ ] Integrate timewarrior
|
||||||
- [ ] Add default timetracking items when addind projects
|
- [ ] Add default timetracking items when addind projects
|
||||||
- [ ] Create interface for timewarrior input
|
- [ ] Create interface for timewarrior input
|
||||||
- [ ] Create daily/weekly reports for HRM input
|
- [ ] Create daily/weekly reports for HRM input
|
||||||
- Combine by project
|
- Combine by project
|
||||||
- Combine by task names
|
- Combine by task names
|
||||||
- Combine by tags
|
- Combine by tags
|
||||||
|
- [ ] Add tag manager
|
||||||
|
- [ ] Edit default tags
|
||||||
|
- [ ] Update to bubbletea 2.0
|
||||||
|
|
||||||
|
# Done
|
||||||
|
- [x] Use JJ
|
||||||
78
timewarrior/config.go
Normal file
78
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
|
||||||
|
}
|
||||||
277
timewarrior/models.go
Normal file
277
timewarrior/models.go
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
package timewarrior
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInterval() *Interval {
|
||||||
|
return &Interval{
|
||||||
|
Tags: make([]string, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Interval) GetString(field string) string {
|
||||||
|
switch field {
|
||||||
|
case "id":
|
||||||
|
return strconv.Itoa(i.ID)
|
||||||
|
|
||||||
|
case "start":
|
||||||
|
return formatDate(i.Start, "formatted")
|
||||||
|
|
||||||
|
case "end":
|
||||||
|
if i.End == "" {
|
||||||
|
return "now"
|
||||||
|
}
|
||||||
|
return formatDate(i.End, "formatted")
|
||||||
|
|
||||||
|
case "tags":
|
||||||
|
if len(i.Tags) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.Join(i.Tags, " ")
|
||||||
|
|
||||||
|
case "duration":
|
||||||
|
return i.GetDuration()
|
||||||
|
|
||||||
|
case "active":
|
||||||
|
if i.End == "" {
|
||||||
|
return "●"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
|
||||||
|
default:
|
||||||
|
slog.Error(fmt.Sprintf("Field not implemented: %s", field))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *Interval) GetDuration() string {
|
||||||
|
start, err := time.Parse(dtformat, i.Start)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to parse start time:", 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:", 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:", 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:", 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:", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
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 "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(fmt.Sprintf("Date format not implemented: %s", format))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDuration(d time.Duration) string {
|
||||||
|
hours := int(d.Hours())
|
||||||
|
minutes := int(d.Minutes()) % 60
|
||||||
|
seconds := int(d.Seconds()) % 60
|
||||||
|
|
||||||
|
if hours > 0 {
|
||||||
|
return fmt.Sprintf("%d:%02d:%02d", hours, minutes, seconds)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d:%02d", 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"
|
||||||
|
}
|
||||||
305
timewarrior/timewarrior.go
Normal file
305
timewarrior/timewarrior.go
Normal file
@ -0,0 +1,305 @@
|
|||||||
|
// TODO: error handling
|
||||||
|
// TODO: split combinedOutput and handle stderr differently
|
||||||
|
package timewarrior
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
twBinary = "timew"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TimeWarrior interface {
|
||||||
|
GetConfig() *TWConfig
|
||||||
|
|
||||||
|
GetTags() []string
|
||||||
|
|
||||||
|
GetIntervals(filter ...string) Intervals
|
||||||
|
StartTracking(tags []string) error
|
||||||
|
StopTracking() error
|
||||||
|
ContinueTracking() error
|
||||||
|
CancelTracking() error
|
||||||
|
DeleteInterval(id int) error
|
||||||
|
ModifyInterval(interval *Interval) error
|
||||||
|
GetSummary(filter ...string) string
|
||||||
|
GetActive() *Interval
|
||||||
|
|
||||||
|
Undo()
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimeSquire struct {
|
||||||
|
configLocation string
|
||||||
|
defaultArgs []string
|
||||||
|
config *TWConfig
|
||||||
|
|
||||||
|
mutex sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTimeSquire(configLocation string) *TimeSquire {
|
||||||
|
if _, err := exec.LookPath(twBinary); err != nil {
|
||||||
|
slog.Error("Timewarrior not found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := &TimeSquire{
|
||||||
|
configLocation: configLocation,
|
||||||
|
defaultArgs: []string{},
|
||||||
|
mutex: sync.Mutex{},
|
||||||
|
}
|
||||||
|
ts.config = ts.extractConfig()
|
||||||
|
|
||||||
|
return ts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimeSquire) GetConfig() *TWConfig {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
return ts.config
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimeSquire) GetTags() []string {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"tags"}...)...)
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed getting tags:", 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimeSquire) GetIntervals(filter ...string) Intervals {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
args := append(ts.defaultArgs, "export")
|
||||||
|
if filter != nil {
|
||||||
|
args = append(args, filter...)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(twBinary, args...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed getting intervals:", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
intervals := make(Intervals, 0)
|
||||||
|
err = json.Unmarshal(output, &intervals)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed unmarshalling intervals:", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign IDs based on reverse chronological order
|
||||||
|
for i := range intervals {
|
||||||
|
intervals[i].ID = len(intervals) - i
|
||||||
|
}
|
||||||
|
|
||||||
|
return intervals
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimeSquire) 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.Command(twBinary, args...)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
slog.Error("Failed starting tracking:", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimeSquire) StopTracking() error {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.Command(twBinary, append(ts.defaultArgs, "stop")...)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
slog.Error("Failed stopping tracking:", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimeSquire) ContinueTracking() error {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.Command(twBinary, append(ts.defaultArgs, "continue")...)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
slog.Error("Failed continuing tracking:", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimeSquire) CancelTracking() error {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.Command(twBinary, append(ts.defaultArgs, "cancel")...)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
slog.Error("Failed canceling tracking:", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimeSquire) DeleteInterval(id int) error {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"delete", fmt.Sprintf("@%d", id)}...)...)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
slog.Error("Failed deleting interval:", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimeSquire) ModifyInterval(interval *Interval) 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:", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import the modified interval
|
||||||
|
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"import"}...)...)
|
||||||
|
cmd.Stdin = bytes.NewBuffer(intervals)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed modifying interval:", err, string(out))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimeSquire) 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.Command(twBinary, args...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed getting summary:", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimeSquire) GetActive() *Interval {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.Command(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
|
||||||
|
intervals := ts.GetIntervals()
|
||||||
|
for _, interval := range intervals {
|
||||||
|
if interval.End == "" {
|
||||||
|
return interval
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimeSquire) Undo() {
|
||||||
|
ts.mutex.Lock()
|
||||||
|
defer ts.mutex.Unlock()
|
||||||
|
|
||||||
|
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"undo"}...)...)
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed undoing:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TimeSquire) extractConfig() *TWConfig {
|
||||||
|
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"show"}...)...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed getting config:", 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