Initial commit

This commit is contained in:
Martin
2024-05-20 21:17:47 +02:00
commit d960f1f113
25 changed files with 1897 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.DS_Store

17
.vscode/launch.json vendored Normal file
View File

@ -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"
}
]
}

27
common/common.go Normal file
View File

@ -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()),
}
}

62
common/keymap.go Normal file
View File

@ -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"),
),
}
}

40
common/stack.go Normal file
View File

@ -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
}

25
common/styles.go Normal file
View File

@ -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")),
}
}

60
flake.lock generated Normal file
View File

@ -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
}

39
flake.nix Normal file
View File

@ -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";
};
});
}

33
go.mod Normal file
View File

@ -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
)

78
go.sum Normal file
View File

@ -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=

42
main.go Normal file
View File

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

53
model/model.go Normal file
View File

@ -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()
}

105
pages/contextPicker.go Normal file
View File

@ -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

122
pages/datePicker.go Normal file
View File

@ -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
// }

11
pages/page.go Normal file
View File

@ -0,0 +1,11 @@
package pages
import (
tea "github.com/charmbracelet/bubbletea"
)
func BackCmd() tea.Msg {
return BackMsg{}
}
type BackMsg struct{}

100
pages/projectPicker.go Normal file
View File

@ -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

201
pages/report.go Normal file
View File

@ -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

97
pages/reportPicker.go Normal file
View File

@ -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

233
pages/taskEditor.go Normal file
View File

@ -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")
}

43
taskwarrior/config.go Normal file
View File

@ -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
}

41
taskwarrior/models.go Normal file
View File

@ -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

398
taskwarrior/taskwarrior.go Normal file
View File

@ -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
}

View File

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

BIN
test/taskchampion.sqlite3 Normal file

Binary file not shown.

4
test/taskrc Normal file
View File

@ -0,0 +1,4 @@
data.location=/Users/moustachioed/projects/tasksquire/test
context.test1.read=+test
context.test1.write=+test
context=test1