From d960f1f1137f20688ed8d9fe34beecf643627ff6 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 20 May 2024 21:17:47 +0200 Subject: [PATCH] Initial commit --- .gitignore | 1 + .vscode/launch.json | 17 ++ common/common.go | 27 +++ common/keymap.go | 62 +++++ common/stack.go | 40 ++++ common/styles.go | 25 ++ flake.lock | 60 +++++ flake.nix | 39 ++++ go.mod | 33 +++ go.sum | 78 +++++++ main.go | 42 ++++ model/model.go | 53 +++++ pages/contextPicker.go | 105 +++++++++ pages/datePicker.go | 122 ++++++++++ pages/page.go | 11 + pages/projectPicker.go | 100 ++++++++ pages/report.go | 201 ++++++++++++++++ pages/reportPicker.go | 97 ++++++++ pages/taskEditor.go | 233 +++++++++++++++++++ taskwarrior/config.go | 43 ++++ taskwarrior/models.go | 41 ++++ taskwarrior/taskwarrior.go | 398 ++++++++++++++++++++++++++++++++ taskwarrior/taskwarrior_test.go | 65 ++++++ test/taskchampion.sqlite3 | Bin 0 -> 49152 bytes test/taskrc | 4 + 25 files changed, 1897 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 common/common.go create mode 100644 common/keymap.go create mode 100644 common/stack.go create mode 100644 common/styles.go create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 model/model.go create mode 100644 pages/contextPicker.go create mode 100644 pages/datePicker.go create mode 100644 pages/page.go create mode 100644 pages/projectPicker.go create mode 100644 pages/report.go create mode 100644 pages/reportPicker.go create mode 100644 pages/taskEditor.go create mode 100644 taskwarrior/config.go create mode 100644 taskwarrior/models.go create mode 100644 taskwarrior/taskwarrior.go create mode 100644 taskwarrior/taskwarrior_test.go create mode 100644 test/taskchampion.sqlite3 create mode 100644 test/taskrc 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 0000000000000000000000000000000000000000..209fe04973075966532196cf2ee50ba202466bae GIT binary patch literal 49152 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