commit d960f1f1137f20688ed8d9fe34beecf643627ff6 Author: Martin Date: Mon May 20 21:17:47 2024 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e43b0f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..c09ba25 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Connect to server", + "type": "go", + "request": "attach", + "mode": "remote", + "remotePath": "${workspaceFolder}", + "port": 43000, + "host": "127.0.0.1" + } + ] +} \ No newline at end of file diff --git a/common/common.go b/common/common.go new file mode 100644 index 0000000..d5f8c4c --- /dev/null +++ b/common/common.go @@ -0,0 +1,27 @@ +package common + +import ( + "context" + + "tasksquire/taskwarrior" + + tea "github.com/charmbracelet/bubbletea" +) + +type Common struct { + Ctx context.Context + PageStack *Stack[tea.Model] + TW taskwarrior.TaskWarrior + Keymap *Keymap + Styles *Styles +} + +func NewCommon(ctx context.Context, tw taskwarrior.TaskWarrior) *Common { + return &Common{ + Ctx: ctx, + PageStack: NewStack[tea.Model](), + TW: tw, + Keymap: NewKeymap(), + Styles: NewStyles(tw.GetConfig()), + } +} diff --git a/common/keymap.go b/common/keymap.go new file mode 100644 index 0000000..487e0c1 --- /dev/null +++ b/common/keymap.go @@ -0,0 +1,62 @@ +package common + +import ( + "github.com/charmbracelet/bubbles/key" +) + +// Keymap is a collection of key bindings. +type Keymap struct { + Quit key.Binding + Back key.Binding + Add key.Binding + Edit key.Binding + SetReport key.Binding + SetContext key.Binding + SetProject key.Binding + Select key.Binding +} + +// 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"), + ), + + Add: key.NewBinding( + key.WithKeys("a"), + key.WithHelp("a", "Add new task"), + ), + + Edit: key.NewBinding( + key.WithKeys("e"), + key.WithHelp("e", "Edit task"), + ), + + 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"), + ), + + Select: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "Select"), + ), + } +} diff --git a/common/stack.go b/common/stack.go new file mode 100644 index 0000000..accb98c --- /dev/null +++ b/common/stack.go @@ -0,0 +1,40 @@ +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 +} diff --git a/common/styles.go b/common/styles.go new file mode 100644 index 0000000..e903bdc --- /dev/null +++ b/common/styles.go @@ -0,0 +1,25 @@ +package common + +import ( + "github.com/charmbracelet/lipgloss" + + "tasksquire/taskwarrior" +) + +type Styles struct { + Main lipgloss.Style + + ActiveTask lipgloss.Style + InactiveTask lipgloss.Style + NormalTask lipgloss.Style +} + +func NewStyles(config *taskwarrior.TWConfig) *Styles { + return &Styles{ + Main: lipgloss.NewStyle().Foreground(lipgloss.Color("241")), + + ActiveTask: lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Background(lipgloss.Color("236")), + InactiveTask: lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Background(lipgloss.Color("236")), + NormalTask: lipgloss.NewStyle().Foreground(lipgloss.Color("241")), + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..38a70dd --- /dev/null +++ b/flake.lock @@ -0,0 +1,60 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1710146030, + "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1715787315, + "narHash": "sha256-cYApT0NXJfqBkKcci7D9Kr4CBYZKOQKDYA23q8XNuWg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "33d1e753c82ffc557b4a585c77de43d4c922ebb5", + "type": "github" + }, + "original": { + "id": "nixpkgs", + "ref": "nixos-unstable", + "type": "indirect" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..0e65656 --- /dev/null +++ b/flake.nix @@ -0,0 +1,39 @@ +{ + description = "Tasksquire"; + + inputs = { + nixpkgs.url = "nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + }; + + buildDeps = with pkgs; [ + go_1_22 + gcc + ]; + + devDeps = with pkgs; buildDeps ++ [ + gotools + golangci-lint + gopls + go-outline + gopkgs + go-tools + gotests + delve + ]; + in + { + devShell = pkgs.mkShell { + buildInputs = devDeps; + CGO_CFLAGS="-O"; + }; + }); +} + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d4f02d1 --- /dev/null +++ b/go.mod @@ -0,0 +1,33 @@ +module tasksquire + +go 1.22.2 + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.2.0 // indirect + github.com/charmbracelet/bubbles v0.18.0 // indirect + github.com/charmbracelet/bubbletea v0.26.1 // indirect + github.com/charmbracelet/huh v0.3.0 // indirect + github.com/charmbracelet/huh/spinner v0.0.0-20240508140610-13957916abf0 // indirect + github.com/charmbracelet/lipgloss v0.10.1-0.20240506202754-3ee5dcab73cb // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240515162549-69ee4f765313 // indirect + github.com/charmbracelet/x/exp/term v0.0.0-20240506152644-8135bef4e495 // indirect + github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/ethanefung/bubble-datepicker v0.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/moustachioed/go-taskwarrior v0.0.0-20220111032313-0ea4f466b47c // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/term v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d540a20 --- /dev/null +++ b/go.sum @@ -0,0 +1,78 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= +github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.17.2-0.20240108170749-ec883029c8e6 h1:6nVCV8pqGaeyxetur3gpX3AAaiyKgzjIoCPV3NXKZBE= +github.com/charmbracelet/bubbles v0.17.2-0.20240108170749-ec883029c8e6/go.mod h1:9HxZWlkCqz2PRwsCbYl7a3KXvGzFaDHpYbSYMJ+nE3o= +github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= +github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= +github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= +github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= +github.com/charmbracelet/bubbletea v0.26.1 h1:xujcQeF73rh4jwu3+zhfQsvV18x+7zIjlw7/CYbzGJ0= +github.com/charmbracelet/bubbletea v0.26.1/go.mod h1:FzKr7sKoO8iFVcdIBM9J0sJOcQv5nDQaYwsee3kpbgo= +github.com/charmbracelet/huh v0.3.0 h1:CxPplWkgW2yUTDDG0Z4S5HH8SJOosWHd4LxCvi0XsKE= +github.com/charmbracelet/huh v0.3.0/go.mod h1:fujUdKX8tC45CCSaRQdw789O6uaCRwx8l2NDyKfC4jA= +github.com/charmbracelet/huh/spinner v0.0.0-20240508140610-13957916abf0 h1:79JTuYRirtyCn9ac6rzPt5AQKtBDFc1gKxpw0wBrI+Y= +github.com/charmbracelet/huh/spinner v0.0.0-20240508140610-13957916abf0/go.mod h1:Zxt9FH6togK9kY71pRJGtmyNkJ1eIWdK1gRaXrS/FKA= +github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= +github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= +github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s= +github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE= +github.com/charmbracelet/lipgloss v0.10.1-0.20240506202754-3ee5dcab73cb h1:Hs3xzxHuruNT2Iuo87iS40c0PhLqpnUKBI6Xw6Ad3wQ= +github.com/charmbracelet/lipgloss v0.10.1-0.20240506202754-3ee5dcab73cb/go.mod h1:EPP2QJ0ectp3zo6gx9f8oJGq8keirqPJ3XpYEI8wrrs= +github.com/charmbracelet/x/exp/strings v0.0.0-20240515162549-69ee4f765313 h1:3RsFsshW5j6I2GcEg7Qy//ZOFBqmwTqQ/KIFpU8kiIM= +github.com/charmbracelet/x/exp/strings v0.0.0-20240515162549-69ee4f765313/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/exp/term v0.0.0-20240506152644-8135bef4e495 h1:+0U9qX8Pv8KiYgRxfBvORRjgBzLgHMjtElP4O0PyKYA= +github.com/charmbracelet/x/exp/term v0.0.0-20240506152644-8135bef4e495/go.mod h1:qeR6w1zITbkF7vEhcx0CqX5GfnIiQloJWQghN6HfP+c= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/ethanefung/bubble-datepicker v0.1.0 h1:dOD6msw3cWZv8O8fvHIPwFWIldtfWT6AfiSsVvZgWWo= +github.com/ethanefung/bubble-datepicker v0.1.0/go.mod h1:8nxOYB9Oqays5U0JHKcIsbT7ZP/TwuJz8Uju9n5ueVU= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/moustachioed/go-taskwarrior v0.0.0-20220111032313-0ea4f466b47c h1:9w8HAhnAJKvNrus/zZM0yMSSeOIh6fdY2vInYPLqZJE= +github.com/moustachioed/go-taskwarrior v0.0.0-20220111032313-0ea4f466b47c/go.mod h1:/sBiMzFAOI/NxkONwgm9omrpBjYlmkmdmZ074a9PbX0= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= +github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= +golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= diff --git a/main.go b/main.go new file mode 100644 index 0000000..818cd54 --- /dev/null +++ b/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "context" + "fmt" + "os" + + "tasksquire/common" + "tasksquire/model" + "tasksquire/taskwarrior" + + tea "github.com/charmbracelet/bubbletea" +) + +func main() { + ts := taskwarrior.NewTaskSquire("./test/taskrc") + ctx := context.Background() + common := common.NewCommon(ctx, ts) + + // form := huh.NewForm( + // huh.NewGroup( + // huh.NewSelect[string](). + // Options(huh.NewOptions(config.Reports...)...). + // Title("Report"). + // Description("Choose the report to display"). + // Value(&report), + // ), + // ) + + // err = form.Run() + // if err != nil { + // slog.Error("Uh oh:", err) + // os.Exit(1) + // } + m := model.NewMainModel(common) + + if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil { + fmt.Println("Error running program:", err) + os.Exit(1) + } + +} diff --git a/model/model.go b/model/model.go new file mode 100644 index 0000000..54abdc4 --- /dev/null +++ b/model/model.go @@ -0,0 +1,53 @@ +package model + +import ( + tea "github.com/charmbracelet/bubbletea" + + "tasksquire/common" + "tasksquire/pages" + "tasksquire/taskwarrior" +) + +type MainModel struct { + common *common.Common + selectedPage tea.Model + selectedTask *taskwarrior.Task + selectedReport *taskwarrior.Report + selectedContext *taskwarrior.Context +} + +func NewMainModel(common *common.Common) *MainModel { + m := &MainModel{ + common: common, + selectedReport: common.TW.GetReport("next"), + selectedContext: common.TW.GetActiveContext(), + } + + m.selectedPage = pages.NewReportPage(common, m.selectedReport) + + return m + +} + +func (m MainModel) Init() tea.Cmd { + return m.selectedPage.Init() +} + +func (m MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + // switch msg := msg.(type) { + // case tea.KeyMsg: + // switch { + // case key.Matches(msg, m.common.Keymap.Add): + // case key.Matches(msg, m.common.Keymap.Edit): + // } + // } + + m.selectedPage, cmd = m.selectedPage.Update(msg) + + return m, cmd +} + +func (m MainModel) View() string { + return m.selectedPage.View() +} diff --git a/pages/contextPicker.go b/pages/contextPicker.go new file mode 100644 index 0000000..de15c4d --- /dev/null +++ b/pages/contextPicker.go @@ -0,0 +1,105 @@ +package pages + +import ( + "log/slog" + "slices" + "tasksquire/common" + "tasksquire/taskwarrior" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" +) + +type ContextPickerPage struct { + common *common.Common + contexts taskwarrior.Contexts + form *huh.Form +} + +func NewContextPickerPage(common *common.Common) *ContextPickerPage { + p := &ContextPickerPage{ + common: common, + contexts: common.TW.GetContexts(), + } + + // if allowAdd { + // fields = append(fields, huh.NewInput(). + // Key("input"). + // Title("Input"). + // Prompt(fmt.Sprintf("Enter a new %s", header)). + // Inline(false), + // ) + // } + + selected := common.TW.GetActiveContext().Name + options := make([]string, 0) + for _, c := range p.contexts { + options = append(options, c.Name) + } + slices.Sort(options) + + p.form = huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Key("context"). + Options(huh.NewOptions(options...)...). + Title("Contexts"). + Description("Choose a context"). + Value(&selected), + ), + ). + WithShowHelp(false). + WithShowErrors(true) + + return p +} + +func (p *ContextPickerPage) Init() tea.Cmd { + return p.form.Init() +} + +func (p *ContextPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, p.common.Keymap.Back): + model, err := p.common.PageStack.Pop() + if err != nil { + slog.Error("page stack empty") + return nil, tea.Quit + } + return model, BackCmd + } + } + + f, cmd := p.form.Update(msg) + if f, ok := f.(*huh.Form); ok { + p.form = f + cmds = append(cmds, cmd) + } + + if p.form.State == huh.StateCompleted { + cmds = append(cmds, p.updateContextCmd) + model, err := p.common.PageStack.Pop() + if err != nil { + slog.Error("page stack empty") + return nil, tea.Quit + } + return model, tea.Batch(cmds...) + } + + return p, tea.Batch(cmds...) +} + +func (p *ContextPickerPage) View() string { + return p.common.Styles.Main.Render(p.form.View()) +} + +func (p *ContextPickerPage) updateContextCmd() tea.Msg { + return UpdateContextMsg(p.common.TW.GetContext(p.form.GetString("context"))) +} + +type UpdateContextMsg *taskwarrior.Context diff --git a/pages/datePicker.go b/pages/datePicker.go new file mode 100644 index 0000000..1bfb703 --- /dev/null +++ b/pages/datePicker.go @@ -0,0 +1,122 @@ +package pages + +// import ( +// "tasksquire/common" + +// "github.com/charmbracelet/bubbles/textinput" +// tea "github.com/charmbracelet/bubbletea" +// "github.com/charmbracelet/lipgloss" +// datepicker "github.com/ethanefung/bubble-datepicker" +// ) + +// type Model struct { +// focus focus +// input textinput.Model +// datepicker datepicker.Model +// } + +// var inputStyles = lipgloss.NewStyle().Padding(1, 1, 0) + +// func initializeModel() tea.Model { +// dp := datepicker.New(time.Now()) + +// input := textinput.New() +// input.Placeholder = "YYYY-MM-DD (enter date)" +// input.Focus() +// input.Width = 20 + +// return Model{ +// focus: FocusInput, +// input: input, +// datepicker: dp, +// } +// } + +// func (m Model) Init() tea.Cmd { +// return textinput.Blink +// } + +// func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +// var cmd tea.Cmd + +// switch msg := msg.(type) { +// case tea.WindowSizeMsg: +// // TODO figure out how we want to size things +// // we'll probably want both bubbles to be vertically stacked +// // and to take as much room as the can +// return m, nil +// case tea.KeyMsg: +// switch msg.String() { +// case "ctrl+c", "q": +// return m, tea.Quit +// case "tab": +// if m.focus == FocusInput { +// m.focus = FocusDatePicker +// m.input.Blur() +// m.input.SetValue(m.datepicker.Time.Format(time.DateOnly)) + +// m.datepicker.SelectDate() +// m.datepicker.SetFocus(datepicker.FocusHeaderMonth) +// m.datepicker = m.datepicker +// return m, nil + +// } +// case "shift+tab": +// if m.focus == FocusDatePicker && m.datepicker.Focused == datepicker.FocusHeaderMonth { +// m.focus = FocusInput +// m.datepicker.Blur() + +// m.input.Focus() +// return m, nil +// } +// } +// } + +// switch m.focus { +// case FocusInput: +// m.input, cmd = m.UpdateInput(msg) +// case FocusDatePicker: +// m.datepicker, cmd = m.UpdateDatepicker(msg) +// case FocusNone: +// // do nothing +// } + +// return m, cmd +// } + +// func (m Model) View() string { +// return lipgloss.JoinVertical(lipgloss.Left, inputStyles.Render(m.input.View()), m.datepicker.View()) +// } + +// func (m *Model) UpdateInput(msg tea.Msg) (textinput.Model, tea.Cmd) { +// var cmd tea.Cmd + +// m.input, cmd = m.input.Update(msg) + +// val := m.input.Value() +// t, err := time.Parse(time.DateOnly, strings.TrimSpace(val)) +// if err == nil { +// m.datepicker.SetTime(t) +// m.datepicker.SelectDate() +// m.datepicker.Blur() +// } +// if err != nil && m.datepicker.Selected { +// m.datepicker.UnselectDate() +// } + +// return m.input, cmd +// } + +// func (m *Model) UpdateDatepicker(msg tea.Msg) (datepicker.Model, tea.Cmd) { +// var cmd tea.Cmd + +// prev := m.datepicker.Time + +// m.datepicker, cmd = m.datepicker.Update(msg) + +// if prev != m.datepicker.Time { +// m.input.SetValue(m.datepicker.Time.Format(time.DateOnly)) +// } + +// return m.datepicker, cmd +// } diff --git a/pages/page.go b/pages/page.go new file mode 100644 index 0000000..4515313 --- /dev/null +++ b/pages/page.go @@ -0,0 +1,11 @@ +package pages + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +func BackCmd() tea.Msg { + return BackMsg{} +} + +type BackMsg struct{} diff --git a/pages/projectPicker.go b/pages/projectPicker.go new file mode 100644 index 0000000..49305a7 --- /dev/null +++ b/pages/projectPicker.go @@ -0,0 +1,100 @@ +package pages + +import ( + "log/slog" + "tasksquire/common" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" +) + +type ProjectPickerPage struct { + common *common.Common + form *huh.Form +} + +func NewProjectPickerPage(common *common.Common, activeProject string) *ProjectPickerPage { + p := &ProjectPickerPage{ + common: common, + } + + var selected string + if activeProject == "" { + selected = "(none)" + } else { + selected = activeProject + } + + projects := common.TW.GetProjects() + options := []string{"(none)"} + options = append(options, projects...) + + p.form = huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Key("project"). + Options(huh.NewOptions(options...)...). + Title("Projects"). + Description("Choose a project"). + Value(&selected), + ), + ). + WithShowHelp(false). + WithShowErrors(false) + + return p +} + +func (p *ProjectPickerPage) Init() tea.Cmd { + return p.form.Init() +} + +func (p *ProjectPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, p.common.Keymap.Back): + model, err := p.common.PageStack.Pop() + if err != nil { + slog.Error("page stack empty") + return nil, tea.Quit + } + return model, BackCmd + } + } + + f, cmd := p.form.Update(msg) + if f, ok := f.(*huh.Form); ok { + p.form = f + cmds = append(cmds, cmd) + } + + if p.form.State == huh.StateCompleted { + cmds = append(cmds, p.updateProjectCmd) + model, err := p.common.PageStack.Pop() + if err != nil { + slog.Error("page stack empty") + return nil, tea.Quit + } + return model, tea.Batch(cmds...) + } + + return p, tea.Batch(cmds...) +} + +func (p *ProjectPickerPage) View() string { + return p.common.Styles.Main.Render(p.form.View()) +} + +func (p *ProjectPickerPage) updateProjectCmd() tea.Msg { + project := p.form.GetString("project") + if project == "(none)" { + project = "" + } + return UpdateProjectMsg(project) +} + +type UpdateProjectMsg string diff --git a/pages/report.go b/pages/report.go new file mode 100644 index 0000000..ddc8ce9 --- /dev/null +++ b/pages/report.go @@ -0,0 +1,201 @@ +package pages + +import ( + "strconv" + "strings" + + "tasksquire/common" + "tasksquire/taskwarrior" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type ReportPage struct { + common *common.Common + + activeReport *taskwarrior.Report + activeContext *taskwarrior.Context + activeProject string + selectedTask *taskwarrior.Task + + tasks taskwarrior.Tasks + + taskTable table.Model + tableStyle table.Styles + keymap ReportKeys + + subpage tea.Model + subpageActive bool +} + +type ReportKeys struct { + Quit key.Binding + Up key.Binding + Down key.Binding + Select key.Binding + ToggleFocus key.Binding +} + +func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage { + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(false) + s.Selected = s.Selected. + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Bold(false) + + keys := ReportKeys{ + Quit: key.NewBinding( + key.WithKeys("q", "ctrl+c"), + key.WithHelp("q, ctrl+c", "Quit"), + ), + Up: key.NewBinding( + key.WithKeys("k", "up"), + key.WithHelp("↑/k", "Up"), + ), + Down: key.NewBinding( + key.WithKeys("j", "down"), + key.WithHelp("↓/j", "Down"), + ), + Select: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "Select"), + ), + ToggleFocus: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "Toggle focus"), + ), + } + + return &ReportPage{ + common: com, + activeReport: report, + activeContext: com.TW.GetActiveContext(), + activeProject: "", + tableStyle: s, + keymap: keys, + } +} + +func (p ReportPage) Init() tea.Cmd { + return p.getTasks() +} + +func (p ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + switch msg := msg.(type) { + case BackMsg: + p.subpageActive = false + case TaskMsg: + p.populateTaskTable(msg) + case UpdateReportMsg: + p.activeReport = msg + cmds = append(cmds, p.getTasks()) + case UpdateContextMsg: + p.activeContext = msg + p.common.TW.SetContext(msg) + cmds = append(cmds, p.getTasks()) + case UpdateProjectMsg: + p.activeProject = string(msg) + cmds = append(cmds, p.getTasks()) + case AddedTaskMsg: + cmds = append(cmds, p.getTasks()) + case tea.WindowSizeMsg: + p.taskTable.SetWidth(msg.Width - 2) + p.taskTable.SetHeight(msg.Height - 4) + case tea.KeyMsg: + switch { + case key.Matches(msg, p.common.Keymap.Quit): + return p, tea.Quit + case key.Matches(msg, p.common.Keymap.SetReport): + p.subpage = NewReportPickerPage(p.common, p.activeReport) + p.subpage.Init() + p.subpageActive = true + p.common.PageStack.Push(p) + return p.subpage, nil + case key.Matches(msg, p.common.Keymap.SetContext): + p.subpage = NewContextPickerPage(p.common) + p.subpage.Init() + p.subpageActive = true + p.common.PageStack.Push(p) + return p.subpage, nil + case key.Matches(msg, p.common.Keymap.Add): + p.subpage = NewTaskEditorPage(p.common, taskwarrior.Task{}) + p.subpage.Init() + p.subpageActive = true + p.common.PageStack.Push(p) + return p.subpage, nil + case key.Matches(msg, p.common.Keymap.SetProject): + p.subpage = NewProjectPickerPage(p.common, p.activeProject) + p.subpage.Init() + p.subpageActive = true + p.common.PageStack.Push(p) + return p.subpage, nil + } + } + + var cmd tea.Cmd + p.taskTable, cmd = p.taskTable.Update(msg) + cmds = append(cmds, cmd) + + if p.tasks != nil { + p.selectedTask = (*taskwarrior.Task)(p.tasks[p.taskTable.Cursor()]) + } + + return p, tea.Batch(cmds...) +} + +func (p ReportPage) View() string { + return p.common.Styles.Main.Render(p.taskTable.View()) + "\n" +} + +func (p *ReportPage) populateTaskTable(tasks []*taskwarrior.Task) { + columns := []table.Column{ + {Title: "ID", Width: 4}, + {Title: "Project", Width: 10}, + {Title: "Tags", Width: 10}, + {Title: "Prio", Width: 2}, + {Title: "Due", Width: 10}, + {Title: "Task", Width: 50}, + } + var rows []table.Row + for _, task := range tasks { + rows = append(rows, table.Row{ + strconv.FormatInt(task.Id, 10), + task.Project, + strings.Join(task.Tags, ", "), + task.Priority, + task.Due, + task.Description, + }) + } + + p.taskTable = table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + // table.WithHeight(7), + // table.WithWidth(100), + ) + p.taskTable.SetStyles(p.tableStyle) +} + +func (p *ReportPage) getTasks() tea.Cmd { + return func() tea.Msg { + filters := []string{} + if p.activeProject != "" { + filters = append(filters, "project:"+p.activeProject) + } + p.tasks = p.common.TW.GetTasks(p.activeReport, filters...) + return TaskMsg(p.tasks) + } +} + +type TaskMsg taskwarrior.Tasks diff --git a/pages/reportPicker.go b/pages/reportPicker.go new file mode 100644 index 0000000..02cc466 --- /dev/null +++ b/pages/reportPicker.go @@ -0,0 +1,97 @@ +package pages + +import ( + "log/slog" + "slices" + "tasksquire/common" + "tasksquire/taskwarrior" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" +) + +type ReportPickerPage struct { + common *common.Common + reports taskwarrior.Reports + form *huh.Form +} + +func NewReportPickerPage(common *common.Common, activeReport *taskwarrior.Report) *ReportPickerPage { + p := &ReportPickerPage{ + common: common, + reports: common.TW.GetReports(), + } + + selected := activeReport.Name + + options := make([]string, 0) + for _, r := range p.reports { + options = append(options, r.Name) + } + slices.Sort(options) + + p.form = huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Key("report"). + Options(huh.NewOptions(options...)...). + Title("Reports"). + Description("Choose a report"). + Value(&selected), + ), + ). + WithShowHelp(false). + WithShowErrors(false) + + return p +} + +func (p *ReportPickerPage) Init() tea.Cmd { + return p.form.Init() +} + +func (p *ReportPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, p.common.Keymap.Back): + model, err := p.common.PageStack.Pop() + if err != nil { + slog.Error("page stack empty") + return nil, tea.Quit + } + return model, BackCmd + } + } + + f, cmd := p.form.Update(msg) + if f, ok := f.(*huh.Form); ok { + p.form = f + cmds = append(cmds, cmd) + } + + if p.form.State == huh.StateCompleted { + cmds = append(cmds, []tea.Cmd{BackCmd, p.updateReportCmd}...) + model, err := p.common.PageStack.Pop() + if err != nil { + slog.Error("page stack empty") + return nil, tea.Quit + } + return model, tea.Batch(cmds...) + } + + return p, tea.Batch(cmds...) +} + +func (p *ReportPickerPage) View() string { + return p.common.Styles.Main.Render(p.form.View()) +} + +func (p *ReportPickerPage) updateReportCmd() tea.Msg { + return UpdateReportMsg(p.common.TW.GetReport(p.form.GetString("report"))) +} + +type UpdateReportMsg *taskwarrior.Report diff --git a/pages/taskEditor.go b/pages/taskEditor.go new file mode 100644 index 0000000..6ff0480 --- /dev/null +++ b/pages/taskEditor.go @@ -0,0 +1,233 @@ +package pages + +import ( + "fmt" + "log/slog" + "tasksquire/common" + "tasksquire/taskwarrior" + "time" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" +) + +type TaskEditorPage struct { + common *common.Common + task taskwarrior.Task + form *huh.Form +} + +type TaskEditorKeys struct { + Quit key.Binding + Up key.Binding + Down key.Binding + Select key.Binding + ToggleFocus key.Binding +} + +func NewTaskEditorPage(common *common.Common, task taskwarrior.Task) *TaskEditorPage { + p := &TaskEditorPage{ + common: common, + task: task, + } + + if p.task.Priority == "" { + p.task.Priority = "(none)" + } + if p.task.Project == "" { + p.task.Project = "(none)" + } + + priorityOptions := append([]string{"(none)"}, common.TW.GetPriorities()...) + projectOptions := append([]string{"(none)"}, common.TW.GetProjects()...) + tagOptions := common.TW.GetTags() + + p.form = huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Task"). + Value(&p.task.Description). + Inline(true), + + huh.NewSelect[string](). + Options(huh.NewOptions(priorityOptions...)...). + Title("Priority"). + Value(&p.task.Priority), + + huh.NewSelect[string](). + Options(huh.NewOptions(projectOptions...)...). + Title("Project"). + Value(&p.task.Project), + + huh.NewMultiSelect[string](). + Options(huh.NewOptions(tagOptions...)...). + Title("Tags"). + Value(&p.task.Tags), + + huh.NewInput(). + Title("Due"). + Value(&p.task.Due). + Validate(validateDate). + Inline(true), + + huh.NewInput(). + Title("Scheduled"). + Value(&p.task.Scheduled). + Validate(validateDate). + Inline(true), + + huh.NewInput(). + Title("Wait"). + Value(&p.task.Wait). + Validate(validateDate). + Inline(true), + ), + ). + WithShowHelp(false). + WithShowErrors(false) + + return p +} + +func (p *TaskEditorPage) Init() tea.Cmd { + return p.form.Init() +} + +func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, p.common.Keymap.Back): + model, err := p.common.PageStack.Pop() + if err != nil { + slog.Error("page stack empty") + return nil, tea.Quit + } + return model, BackCmd + } + } + + f, cmd := p.form.Update(msg) + if f, ok := f.(*huh.Form); ok { + p.form = f + cmds = append(cmds, cmd) + } + + if p.form.State == huh.StateCompleted { + cmds = append(cmds, p.addTaskCmd) + model, err := p.common.PageStack.Pop() + if err != nil { + slog.Error("page stack empty") + return nil, tea.Quit + } + return model, tea.Batch(cmds...) + } + + return p, tea.Batch(cmds...) +} + +func (p *TaskEditorPage) View() string { + return p.common.Styles.Main.Render(p.form.View()) +} + +func (p *TaskEditorPage) addTaskCmd() tea.Msg { + p.common.TW.AddTask(&p.task) + return AddedTaskMsg{} +} + +type AddedTaskMsg struct{} + +// TODO: move this to taskwarrior; add missing date formats +func validateDate(s string) error { + formats := []string{ + "2006-01-02", + "2006-01-02T15:04", + "20060102T150405Z", + } + + otherFormats := []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", + } + + for _, f := range formats { + if _, err := time.Parse(f, s); err == nil { + return nil + } + } + + for _, f := range otherFormats { + if s == f { + return nil + } + } + + return fmt.Errorf("invalid date") +} diff --git a/taskwarrior/config.go b/taskwarrior/config.go new file mode 100644 index 0000000..29686d0 --- /dev/null +++ b/taskwarrior/config.go @@ -0,0 +1,43 @@ +package taskwarrior + +import ( + "fmt" + "log/slog" + "strings" +) + +type TWConfig struct { + config map[string]string +} + +func NewConfig(config []string) *TWConfig { + return &TWConfig{ + config: parseConfig(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 +} diff --git a/taskwarrior/models.go b/taskwarrior/models.go new file mode 100644 index 0000000..51cd4cf --- /dev/null +++ b/taskwarrior/models.go @@ -0,0 +1,41 @@ +package taskwarrior + +type Task struct { + Id int64 `json:"id"` + Uuid string `json:"uuid"` + Description string `json:"description"` + Project string `json:"project"` + Priority string `json:"priority"` + Status string `json:"status"` + Tags []string `json:"tags"` + Urgency float32 `json:"urgency"` + Due string `json:"due"` + Wait string `json:"wait"` + Scheduled string `json:"scheduled"` + End string `json:"end"` + Entry string `json:"entry"` + Modified string `json:"modified"` +} + +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 diff --git a/taskwarrior/taskwarrior.go b/taskwarrior/taskwarrior.go new file mode 100644 index 0000000..4542faf --- /dev/null +++ b/taskwarrior/taskwarrior.go @@ -0,0 +1,398 @@ +package taskwarrior + +import ( + "encoding/json" + "fmt" + "log/slog" + "os/exec" + "regexp" + "slices" + "strings" + "sync" +) + +const ( + twBinary = "task" +) + +var ( + tagBlacklist = 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 + + GetTasks(report *Report, filter ...string) Tasks + AddTask(task *Task) error +} + +type TaskSquire struct { + configLocation string + defaultArgs []string + config *TWConfig + reports Reports + contexts Contexts + + mutex sync.Mutex +} + +func NewTaskSquire(configLocation string) *TaskSquire { + 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 := &TaskSquire{ + configLocation: configLocation, + defaultArgs: defaultArgs, + mutex: sync.Mutex{}, + } + ts.config = ts.extractConfig() + ts.reports = ts.extractReports() + ts.contexts = ts.extractContexts() + + return ts +} + +func (ts *TaskSquire) GetConfig() *TWConfig { + ts.mutex.Lock() + defer ts.mutex.Unlock() + + return ts.config +} + +func (ts *TaskSquire) GetTasks(report *Report, filter ...string) Tasks { + ts.mutex.Lock() + defer ts.mutex.Unlock() + + args := ts.defaultArgs + + if report.Context { + for _, context := range ts.contexts { + if context.Active && context.Name != "none" { + args = append(args, context.ReadFilter) + } + } + } + + if filter != nil { + args = append(args, filter...) + } + + cmd := exec.Command(twBinary, append(args, []string{"export", report.Name}...)...) + output, err := cmd.CombinedOutput() + if err != nil { + slog.Error("Failed getting report:", err) + return nil + } + + tasks := make(Tasks, 0) + err = json.Unmarshal(output, &tasks) + if err != nil { + slog.Error("Failed unmarshalling tasks:", err) + return nil + } + + return tasks +} + +func (ts *TaskSquire) GetContext(context string) *Context { + ts.mutex.Lock() + defer ts.mutex.Unlock() + + if context, ok := ts.contexts[context]; ok { + return context + } else { + slog.Error(fmt.Sprintf("Context not found: %s", context.Name)) + return nil + } +} + +func (ts *TaskSquire) 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 *TaskSquire) GetContexts() Contexts { + ts.mutex.Lock() + defer ts.mutex.Unlock() + + return ts.contexts +} + +func (ts *TaskSquire) GetProjects() []string { + ts.mutex.Lock() + defer ts.mutex.Unlock() + + cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_projects"}...)...) + + output, err := cmd.CombinedOutput() + if err != nil { + slog.Error("Failed getting projects:", 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 *TaskSquire) 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 *TaskSquire) 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) + + for _, tag := range strings.Split(string(output), "\n") { + if _, ok := tagBlacklist[tag]; !ok && tag != "" { + tags = append(tags, tag) + } + } + + slices.Sort(tags) + + return tags +} + +func (ts *TaskSquire) GetReport(report string) *Report { + ts.mutex.Lock() + defer ts.mutex.Unlock() + + return ts.reports[report] +} + +func (ts *TaskSquire) GetReports() Reports { + ts.mutex.Lock() + defer ts.mutex.Unlock() + + return ts.reports +} + +func (ts *TaskSquire) SetContext(context *Context) error { + ts.mutex.Lock() + defer ts.mutex.Unlock() + + cmd := exec.Command(twBinary, []string{"context", context.Name}...) + if err := cmd.Run(); err != nil { + slog.Error("Failed setting context:", 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 *TaskSquire) 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 +} + +func (ts *TaskSquire) 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 (ts *TaskSquire) extractReports() Reports { + cmd := exec.Command(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 { + 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 *TaskSquire) extractContexts() Contexts { + cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_context"}...)...) + + output, err := cmd.CombinedOutput() + if err != nil { + slog.Error("Failed getting contexts:", err) + 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 +} diff --git a/taskwarrior/taskwarrior_test.go b/taskwarrior/taskwarrior_test.go new file mode 100644 index 0000000..823b7f6 --- /dev/null +++ b/taskwarrior/taskwarrior_test.go @@ -0,0 +1,65 @@ +package taskwarrior + +import ( + "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(tt.fields.configLocation) + if got := ts.GetActiveContext(); got.Name != tt.want { + t.Errorf("TaskSquire.GetContext() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/test/taskchampion.sqlite3 b/test/taskchampion.sqlite3 new file mode 100644 index 0000000..209fe04 Binary files /dev/null and b/test/taskchampion.sqlite3 differ diff --git a/test/taskrc b/test/taskrc new file mode 100644 index 0000000..a157542 --- /dev/null +++ b/test/taskrc @@ -0,0 +1,4 @@ +data.location=/Users/moustachioed/projects/tasksquire/test +context.test1.read=+test +context.test1.write=+test +context=test1