43 Commits

Author SHA1 Message Date
Martin Pander
f0a3e0a568 Fix config 2026-02-26 22:49:00 +01:00
Martin Pander
9eda92503e Use native bubble table 2026-02-26 22:47:31 +01:00
Martin Pander
418bcd96a8 Tear down everything
Fix config
2026-02-26 22:47:12 +01:00
Martin Pander
6b1418fc71 Update flake to flake-parts 2026-02-23 21:30:53 +01:00
Martin Pander
b46aced2c7 Align subpages 2026-02-17 21:31:24 +01:00
Martin Pander
3ab26f658d Unify styles 2026-02-17 21:25:14 +01:00
Martin Pander
1a9fd9b4b0 Clean up task editor and time editor 2026-02-17 20:57:21 +01:00
Martin Pander
6e60698526 Merge branch 'feat/time' into dev 2026-02-10 15:54:31 +01:00
Martin Pander
703ed981ac Add things 2026-02-10 15:54:08 +01:00
Martin Pander
e3effe8b25 Minor fixes 2026-02-07 20:48:26 +01:00
Martin Pander
980c8eb309 Move log 2026-02-07 20:44:58 +01:00
Martin Pander
e35f480248 Update flake 2026-02-07 20:32:52 +01:00
Martin Pander
02fa2e503a Add things 2026-02-04 13:13:04 +01:00
Martin
474bb3dc07 Add project tracking picker 2026-02-03 20:59:47 +01:00
Martin
1ffcf42773 Fix bugs 2026-02-03 20:13:09 +01:00
Martin Pander
44ddbc0f47 Add syncing 2026-02-03 16:04:47 +01:00
Martin Pander
2e33893e29 Merge branch 'feat/taskedit' into feat/time 2026-02-03 07:40:11 +01:00
Martin Pander
46ce91196a Merge branch 'feat/task' into feat/time 2026-02-03 07:39:59 +01:00
Martin Pander
2b31d9bc2b Add autocompletion 2026-02-03 07:39:26 +01:00
Martin
70b6ee9bc7 Add picker to task edit 2026-02-02 20:43:08 +01:00
Martin
2baf3859fd Add tab bar 2026-02-02 19:47:18 +01:00
Martin
2940711b26 Make task details scrollable 2026-02-02 19:39:02 +01:00
Martin Pander
f5d297e6ab Add proper fuzzy matching for time tags 2026-02-02 15:54:39 +01:00
Martin Pander
938ed177f1 Add fuzzy matching for time tags 2026-02-02 15:41:53 +01:00
Martin Pander
81b9d87935 Add niceties to time page 2026-02-02 12:44:12 +01:00
Martin Pander
9940316ace Add time undo and fill 2026-02-02 11:12:09 +01:00
Martin Pander
fc8e9481c3 Add timestamp editor 2026-02-02 10:55:47 +01:00
Martin
7032d0fa54 Add time editing 2026-02-02 10:04:54 +01:00
Martin
681ed7e635 Add time page 2026-02-02 10:04:54 +01:00
Martin
effd95f6c1 Refactor picker 2026-02-02 10:04:54 +01:00
Martin
4767a6cd91 Integrate timewarrior 2026-02-02 10:04:54 +01:00
Martin
ce193c336c Add README 2026-02-02 10:04:54 +01:00
Martin Pander
f19767fb10 Minor fixes 2026-02-02 10:04:31 +01:00
Martin
82c41a22d2 Update db 2024-06-24 16:47:54 +02:00
Martin
73d51b956a Fix pickers; Add new select option 2024-06-24 16:36:11 +02:00
Martin Pander
fac7ff81dd Add tasks 2024-06-12 07:42:54 +02:00
Martin
0d55a3b119 Add new options in multiselect 2024-06-11 21:48:13 +02:00
Martin
c660b6cbb1 Update tasks 2024-06-09 21:55:45 +02:00
Martin
98d2d041d6 Add details editing 2024-06-09 21:46:39 +02:00
Martin
bafd8958d4 Handle UDAs for editing; Fix layout; Add annotations 2024-06-09 17:55:56 +02:00
Martin Pander
3e1cb9d1bc Next/Prev task edit 2024-06-05 16:29:47 +02:00
Martin Pander
0572763e31 Fixes 2024-06-04 16:45:57 +02:00
Martin
9aa7b04b98 Fix UDA colors 2024-05-31 13:40:49 +02:00
48 changed files with 4369 additions and 3411 deletions

View File

@@ -0,0 +1,12 @@
{
"permissions": {
"allow": [
"Bash(find:*)",
"Bash(ls:*)",
"Bash(go fmt:*)",
"Bash(go build:*)",
"Bash(go vet:*)",
"Bash(timew export:*)"
]
}
}

5
.gitignore vendored
View File

@@ -1,3 +1,6 @@
.DS_Store
app.log
test/taskchampion.sqlite3
tasksquire
test/*.sqlite3*
result
main

207
AGENTS.md Normal file
View File

@@ -0,0 +1,207 @@
# Agent Development Guide for TaskSquire
This guide is for AI coding agents working on TaskSquire, a Go-based TUI (Terminal User Interface) for Taskwarrior.
## Project Overview
- **Language**: Go 1.22.2
- **Architecture**: Model-View-Update (MVU) pattern using Bubble Tea framework
- **Module**: `tasksquire`
- **Main Dependencies**: Bubble Tea, Lip Gloss, Huh, Bubbles (Charm ecosystem)
## Build, Test, and Lint Commands
### Building and Running
```bash
# Run directly
go run main.go
# Build binary
go build -o tasksquire main.go
# Run tests
go test ./...
# Run tests for a specific package
go test ./taskwarrior
# Run a single test
go test ./taskwarrior -run TestTaskSquire_GetContext
# Run tests with verbose output
go test -v ./taskwarrior
# Run tests with coverage
go test -cover ./...
```
### Linting and Formatting
```bash
# Format code (always run before committing)
go fmt ./...
# Lint with golangci-lint (available via nix-shell)
golangci-lint run
# Vet code for suspicious constructs
go vet ./...
# Tidy dependencies
go mod tidy
```
### Development Environment
```bash
# Enter Nix development shell (provides all tools)
nix develop
# Or use direnv (automatically loads .envrc)
direnv allow
```
## Project Structure
```
tasksquire/
├── main.go # Entry point: initializes TaskSquire, TimeSquire, and Bubble Tea
├── common/ # Shared state, components interface, keymaps, styles, utilities
├── pages/ # UI pages/views (report, taskEditor, timePage, pickers, etc.)
├── components/ # Reusable UI components (input, table, timetable, picker)
├── taskwarrior/ # Taskwarrior CLI wrapper, models, config
├── timewarrior/ # Timewarrior integration, models, config
└── test/ # Test fixtures and data
```
## Code Style Guidelines
### Imports
- **Standard Library First**: Group standard library imports, then third-party, then local
- **Local Import Pattern**: Use `tasksquire/<package>` for internal imports
```go
import (
"context"
"fmt"
"log/slog"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"tasksquire/common"
"tasksquire/taskwarrior"
)
```
### Naming Conventions
- **Exported Types**: PascalCase (e.g., `TaskSquire`, `ReportPage`, `Common`)
- **Unexported Fields**: camelCase (e.g., `configLocation`, `activeReport`, `pageStack`)
- **Interfaces**: Follow Go convention, often ending in 'er' (e.g., `TaskWarrior`, `TimeWarrior`, `Component`)
- **Constants**: PascalCase or SCREAMING_SNAKE_CASE for exported constants
- **Test Functions**: `TestFunctionName` or `TestType_Method`
### Types and Interfaces
- **Interface-Based Design**: Use interfaces for main abstractions (see `TaskWarrior`, `TimeWarrior`, `Component`)
- **Struct Composition**: Embed common state (e.g., pages embed or reference `*common.Common`)
- **Pointer Receivers**: Use pointer receivers for methods that modify state or for consistency
- **Generic Types**: Use generics where appropriate (e.g., `Stack[T]` in `common/stack.go`)
### Error Handling
- **Logging Over Panicking**: Use `log/slog` for structured logging, typically continue execution
- **Error Returns**: Return errors from functions, don't log and return
- **Context**: Errors are often logged with `slog.Error()` or `slog.Warn()` and execution continues
```go
// Typical pattern
if err != nil {
slog.Error("Failed to get tasks", "error", err)
return nil // or continue with default behavior
}
```
### Concurrency and Thread Safety
- **Mutex Protection**: Use `sync.Mutex` to protect shared state (see `TaskSquire.mu`)
- **Lock Pattern**: Lock before operations, defer unlock
```go
ts.mu.Lock()
defer ts.mu.Unlock()
```
### Configuration and Environment
- **Environment Variables**: Respect `TASKRC` and `TIMEWARRIORDB`
- **Fallback Paths**: Check standard locations (`~/.taskrc`, `~/.config/task/taskrc`)
- **Config Parsing**: Parse Taskwarrior config format manually (see `taskwarrior/config.go`)
### MVU Pattern (Bubble Tea)
- **Components Implement**: `Init() tea.Cmd`, `Update(tea.Msg) (tea.Model, tea.Cmd)`, `View() string`
- **Custom Messages**: Define custom message types for inter-component communication
- **Cmd Chaining**: Return commands from Init/Update to trigger async operations
```go
type MyMsg struct {
data string
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case MyMsg:
// Handle custom message
return m, nil
}
return m, nil
}
```
### Styling with Lip Gloss
- **Centralized Styles**: Define styles in `common/styles.go`
- **Theme Colors**: Parse colors from Taskwarrior config
- **Reusable Styles**: Create style functions, not inline styles
### Testing
- **Table-Driven Tests**: Use struct slices for test cases
- **Test Setup**: Create helper functions like `TaskWarriorTestSetup()`
- **Temp Directories**: Use `t.TempDir()` for isolated test environments
- **Prep Functions**: Include `prep func()` in test cases for setup
### Documentation
- **TODO Comments**: Mark future improvements with `// TODO: description`
- **Package Comments**: Document package purpose at the top of main files
- **Exported Functions**: Document exported functions, types, and methods
## Common Patterns
### Page Navigation
- Pages pushed onto stack via `common.PushPage()`
- Pop pages with `common.PopPage()`
- Check for subpages with `common.HasSubpages()`
### Task Operations
```go
// Get tasks for a report
tasks := ts.GetTasks(report, "filter", "args")
// Import/create task
ts.ImportTask(&task)
// Mark task done
ts.SetTaskDone(&task)
// Start/stop task
ts.StartTask(&task)
ts.StopTask(&task)
```
### JSON Handling
- Custom Marshal/Unmarshal for Task struct to handle UDAs (User Defined Attributes)
- Use `json.RawMessage` for flexible field handling
## Key Files to Reference
- `common/component.go` - Component interface definition
- `common/common.go` - Shared state container
- `taskwarrior/taskwarrior.go` - TaskWarrior interface and implementation
- `pages/main.go` - Main page router pattern
- `taskwarrior/models.go` - Data model examples
## Development Notes
- **Logging**: Application logs to `/tmp/tasksquire.log`
- **Virtual Tags**: Filter out Taskwarrior virtual tags (see `virtualTags` map)
- **Color Parsing**: Custom color parsing from Taskwarrior config format
- **Debugging**: VSCode launch.json configured for remote debugging on port 43000

236
CLAUDE.md Normal file
View File

@@ -0,0 +1,236 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
TaskSquire is a Go-based Terminal User Interface (TUI) for Taskwarrior and Timewarrior. It uses the Bubble Tea framework (Model-View-Update pattern) from the Charm ecosystem.
**Key Technologies:**
- Go 1.22.2
- Bubble Tea (MVU pattern)
- Lip Gloss (styling)
- Huh (forms)
- Bubbles (components)
## Build and Development Commands
### Running and Building
```bash
# Run directly
go run main.go
# Build binary
go build -o tasksquire main.go
# Format code (always run before committing)
go fmt ./...
# Vet code
go vet ./...
# Tidy dependencies
go mod tidy
```
### Testing
```bash
# Run all tests
go test ./...
# Run tests for specific package
go test ./taskwarrior
# Run single test
go test ./taskwarrior -run TestTaskSquire_GetContext
# Run with verbose output
go test -v ./...
# Run with coverage
go test -cover ./...
```
### Linting
```bash
# Lint with golangci-lint (via nix-shell)
golangci-lint run
```
### Development Environment
The project uses Nix for development environment setup:
```bash
# Enter Nix development shell
nix develop
# Or use direnv (automatically loads .envrc)
direnv allow
```
## Architecture
### High-Level Structure
TaskSquire follows the MVU (Model-View-Update) pattern with a component-based architecture:
1. **Entry Point (`main.go`)**: Initializes TaskSquire and TimeSquire, creates Common state container, and starts Bubble Tea program
2. **Common State (`common/`)**: Shared state, components interface, keymaps, styles, and utilities
3. **Pages (`pages/`)**: Top-level UI views (report, taskEditor, timePage, pickers)
4. **Components (`components/`)**: Reusable UI widgets (input, table, timetable, picker)
5. **Business Logic**:
- `taskwarrior/`: Wraps Taskwarrior CLI, models, and config parsing
- `timewarrior/`: Wraps Timewarrior CLI, models, and config parsing
### Component System
All UI elements implement the `Component` interface:
```go
type Component interface {
tea.Model // Init(), Update(tea.Msg), View()
SetSize(width int, height int)
}
```
Components can be composed hierarchically. The `Common` struct manages a page stack for navigation.
### Page Navigation Pattern
- **Main Page** (`pages/main.go`): Root page with tab switching between Tasks and Time views
- **Page Stack**: Managed by `common.Common`, allows pushing/popping subpages
- `common.PushPage(page)` - push a new page on top
- `common.PopPage()` - return to previous page
- `common.HasSubpages()` - check if subpages are active
- **Tab Switching**: Only works at top level (when no subpages active)
### State Management
The `common.Common` struct acts as a shared state container:
- `TW`: TaskWarrior interface for task operations
- `TimeW`: TimeWarrior interface for time tracking
- `Keymap`: Centralized key bindings
- `Styles`: Centralized styling (parsed from Taskwarrior config)
- `Udas`: User Defined Attributes from Taskwarrior config
- `pageStack`: Stack-based page navigation
### Taskwarrior Integration
The `TaskWarrior` interface provides all task operations:
- Task CRUD: `GetTasks()`, `ImportTask()`, `SetTaskDone()`
- Task control: `StartTask()`, `StopTask()`, `DeleteTask()`
- Context management: `GetContext()`, `GetContexts()`, `SetContext()`
- Reports: `GetReport()`, `GetReports()`
- Config parsing: Manual parsing of Taskwarrior config format
All Taskwarrior operations use `exec.Command()` to call the `task` CLI binary. Results are parsed from JSON output.
### Timewarrior Integration
The `TimeWarrior` interface provides time tracking operations:
- Interval management: `GetIntervals()`, `ModifyInterval()`, `DeleteInterval()`
- Tracking control: `StartTracking()`, `StopTracking()`, `ContinueTracking()`
- Tag management: `GetTags()`, `GetTagCombinations()`
- Utility: `FillInterval()`, `JoinInterval()`, `Undo()`
Similar to TaskWarrior, uses `exec.Command()` to call the `timew` CLI binary.
### Custom JSON Marshaling
The `Task` struct uses custom `MarshalJSON()` and `UnmarshalJSON()` to handle:
- User Defined Attributes (UDAs) stored in `Udas map[string]any`
- Dynamic field handling via `json.RawMessage`
- Virtual tags (filtered from regular tags)
### Configuration and Environment
- **Taskwarrior Config**: Located via `TASKRC` env var, or fallback to `~/.taskrc` or `~/.config/task/taskrc`
- **Timewarrior Config**: Located via `TIMEWARRIORDB` env var, or fallback to `~/.timewarrior/timewarrior.cfg`
- **Config Parsing**: Custom parser in `taskwarrior/config.go` handles Taskwarrior's config format
- **Theme Colors**: Extracted from Taskwarrior config and used in Lip Gloss styles
### Concurrency
- Both `TaskSquire` and `TimeSquire` use `sync.Mutex` to protect shared state
- Lock pattern: `ts.mu.Lock()` followed by `defer ts.mu.Unlock()`
- Operations are synchronous (no goroutines in typical flows)
### Logging
- Uses `log/slog` for structured logging
- Logs written to `/tmp/tasksquire.log`
- Errors logged but execution typically continues (graceful degradation)
- Log pattern: `slog.Error("message", "key", value)`
## Code Style and Patterns
### Import Organization
Standard library first, then third-party, then local:
```go
import (
"context"
"fmt"
tea "github.com/charmbracelet/bubbletea"
"tasksquire/common"
"tasksquire/taskwarrior"
)
```
### Naming Conventions
- Exported types: `PascalCase` (e.g., `TaskSquire`, `ReportPage`)
- Unexported fields: `camelCase` (e.g., `configLocation`, `activeReport`)
- Interfaces: Often end in 'er' or describe capability (e.g., `TaskWarrior`, `TimeWarrior`, `Component`)
### Error Handling
- Log errors with `slog.Error()` and continue execution
- Don't panic unless fatal initialization error
- Return errors from functions, don't log and return
### MVU Pattern in Bubble Tea
Components follow the MVU pattern:
- `Init() tea.Cmd`: Initialize and return commands for side effects
- `Update(tea.Msg) (tea.Model, tea.Cmd)`: Handle messages, update state, return commands
- `View() string`: Render UI as string
Custom messages for inter-component communication:
```go
type MyCustomMsg struct {
data string
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case MyCustomMsg:
// Handle custom message
}
return m, nil
}
```
### Styling with Lip Gloss
- Centralized styles in `common/styles.go`
- Theme colors parsed from Taskwarrior config
- Create reusable style functions, not inline styles
### Testing Patterns
- Table-driven tests with struct slices
- Helper functions like `TaskWarriorTestSetup()`
- Use `t.TempDir()` for isolated test environments
- Include `prep func()` in test cases for setup
## Important Implementation Details
### Virtual Tags
Taskwarrior has virtual tags (ACTIVE, BLOCKED, etc.) that are filtered out from regular tags. See the `virtualTags` map in `taskwarrior/taskwarrior.go`.
### Non-Standard Reports
Some Taskwarrior reports require special handling (burndown, calendar, etc.). See `nonStandardReports` map.
### Timestamp Format
Taskwarrior uses ISO 8601 format: `20060102T150405Z` (defined as `dtformat` constant)
### Color Parsing
Custom color parsing from Taskwarrior config format in `common/styles.go`
### VSCode Debugging
Launch configuration available for remote debugging on port 43000 (see `.vscode/launch.json`)

71
GEMINI.md Normal file
View File

@@ -0,0 +1,71 @@
# Tasksquire
## Project Overview
Tasksquire is a Terminal User Interface (TUI) for [Taskwarrior](https://taskwarrior.org/), built using Go and the [Charm](https://charm.sh/) ecosystem (Bubble Tea, Lip Gloss, Huh). It provides a visual and interactive way to manage your tasks, contexts, and reports directly from the terminal.
The application functions as a wrapper around the `task` command-line tool, parsing its output (JSON, config) and executing commands to read and modify task data.
## Architecture
The project follows the standard [Bubble Tea](https://github.com/charmbracelet/bubbletea) Model-View-Update (MVU) architecture.
### Key Directories & Files
* **`main.go`**: The entry point of the application. It initializes the `TaskSquire` wrapper, sets up logging, and starts the Bubble Tea program with the `MainPage`.
* **`taskwarrior/`**: Contains the logic for interacting with the Taskwarrior CLI.
* `taskwarrior.go`: The core wrapper (`TaskSquire` struct) that executes `task` commands (`export`, `add`, `modify`, etc.) and parses results.
* `models.go`: Defines the Go structs matching Taskwarrior's data model (Tasks, Reports, Config).
* **`pages/`**: Contains the different views of the application.
* `main.go`: The top-level component (`MainPage`) that manages routing/switching between different pages.
* `report.go`: Displays lists of tasks (Taskwarrior reports).
* `taskEditor.go`: UI for creating or editing tasks.
* **`common/`**: Shared utilities, global state, and data structures used across the application.
* **`components/`**: Reusable UI components (e.g., inputs, tables).
* **`timewarrior/`**: Contains logic for integration with Timewarrior (likely in progress or planned).
## Building and Running
### Prerequisites
* **Go**: Version 1.22 or higher.
* **Taskwarrior**: The `task` binary must be installed and available in your system's `PATH`.
### Commands
To run the application directly:
```bash
go run main.go
```
To build a binary:
```bash
go build -o tasksquire main.go
```
### Nix Support
This project includes a `flake.nix` for users of the Nix package manager. You can enter a development shell with all dependencies (Go, tools) by running:
```bash
nix develop
```
## Configuration
Tasksquire respects your existing Taskwarrior configuration (`.taskrc`). It looks for the configuration file in the following order:
1. `TASKRC` environment variable.
2. `$HOME/.taskrc`
3. `$HOME/.config/task/taskrc`
Logging is written to `/tmp/tasksquire.log`.
## Development Conventions
* **UI Framework**: Uses [Bubble Tea](https://github.com/charmbracelet/bubbletea) for the TUI loop.
* **Styling**: Uses [Lip Gloss](https://github.com/charmbracelet/lipgloss) for terminal styling.
* **Forms**: Uses [Huh](https://github.com/charmbracelet/huh) for form inputs.
* **Logging**: Uses `log/slog` for structured logging.

20
README.md Normal file
View File

@@ -0,0 +1,20 @@
# TODO
- [>] Add default tags
- Default tags should be defined in the config and always displayed in the tag picker
- [ ] Add project manager
- [ ] Add projects that are always displayed in the project picker
- Saved in config
- [ ] Remove/archive projects
- [ ] Integrate timewarrior
- [ ] Add default timetracking items when addind projects
- [ ] Create interface for timewarrior input
- [ ] Create daily/weekly reports for HRM input
- Combine by project
- Combine by task names
- Combine by tags
- [ ] Add tag manager
- [ ] Edit default tags
- [ ] Update to bubbletea 2.0
# Done
- [x] Use JJ

View File

@@ -1,315 +0,0 @@
package common
import (
"log/slog"
"strconv"
"strings"
// "github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"tasksquire/taskwarrior"
)
type TableStyle struct {
Header lipgloss.Style
Cell lipgloss.Style
Selected lipgloss.Style
}
type Styles struct {
Base lipgloss.Style
Form *huh.Theme
TableStyle TableStyle
ColumnFocused lipgloss.Style
ColumnBlurred lipgloss.Style
ColumnInsert lipgloss.Style
// TODO: make color config completely dynamic to account for keyword., project., tag. and uda. colors
Active lipgloss.Style
Alternate lipgloss.Style
Blocked lipgloss.Style
Blocking lipgloss.Style
BurndownDone lipgloss.Style
BurndownPending lipgloss.Style
BurndownStarted lipgloss.Style
CalendarDue lipgloss.Style
CalendarDueToday lipgloss.Style
CalendarHoliday lipgloss.Style
CalendarOverdue lipgloss.Style
CalendarScheduled lipgloss.Style
CalendarToday lipgloss.Style
CalendarWeekend lipgloss.Style
CalendarWeeknumber lipgloss.Style
Completed lipgloss.Style
Debug lipgloss.Style
Deleted lipgloss.Style
Due lipgloss.Style
DueToday lipgloss.Style
Error lipgloss.Style
Footnote lipgloss.Style
Header lipgloss.Style
HistoryAdd lipgloss.Style
HistoryDelete lipgloss.Style
HistoryDone lipgloss.Style
Label lipgloss.Style
LabelSort lipgloss.Style
Overdue lipgloss.Style
ProjectNone lipgloss.Style
Recurring lipgloss.Style
Scheduled lipgloss.Style
SummaryBackground lipgloss.Style
SummaryBar lipgloss.Style
SyncAdded lipgloss.Style
SyncChanged lipgloss.Style
SyncRejected lipgloss.Style
TagNext lipgloss.Style
TagNone lipgloss.Style
Tagged lipgloss.Style
UdaPriorityH lipgloss.Style
UdaPriorityL lipgloss.Style
UdaPriorityM lipgloss.Style
UndoAfter lipgloss.Style
UndoBefore lipgloss.Style
Until lipgloss.Style
Warning lipgloss.Style
}
func NewStyles(config *taskwarrior.TWConfig) *Styles {
styles := parseColors(config.GetConfig())
styles.Base = lipgloss.NewStyle()
styles.TableStyle = TableStyle{
// Header: lipgloss.NewStyle().Bold(true).Padding(0, 1).BorderBottom(true),
Cell: lipgloss.NewStyle().Padding(0, 1, 0, 0),
Header: lipgloss.NewStyle().Bold(true).Padding(0, 1, 0, 0).Underline(true),
Selected: lipgloss.NewStyle().Bold(true).Reverse(true),
}
formTheme := huh.ThemeBase()
formTheme.Focused.Title = formTheme.Focused.Title.Bold(true)
formTheme.Focused.SelectSelector = formTheme.Focused.SelectSelector.SetString("> ")
formTheme.Focused.SelectedOption = formTheme.Focused.SelectedOption.Bold(true)
formTheme.Focused.MultiSelectSelector = formTheme.Focused.MultiSelectSelector.SetString("> ")
formTheme.Focused.SelectedPrefix = formTheme.Focused.SelectedPrefix.SetString("✓ ")
formTheme.Focused.UnselectedPrefix = formTheme.Focused.SelectedPrefix.SetString("• ")
formTheme.Blurred.SelectSelector = formTheme.Blurred.SelectSelector.SetString(" ")
formTheme.Blurred.SelectedOption = formTheme.Blurred.SelectedOption.Bold(true)
formTheme.Blurred.MultiSelectSelector = formTheme.Blurred.MultiSelectSelector.SetString(" ")
formTheme.Blurred.SelectedPrefix = formTheme.Blurred.SelectedPrefix.SetString("✓ ")
formTheme.Blurred.UnselectedPrefix = formTheme.Blurred.SelectedPrefix.SetString("• ")
styles.Form = formTheme
styles.ColumnFocused = lipgloss.NewStyle().Width(30).Height(50).Border(lipgloss.DoubleBorder(), true)
styles.ColumnBlurred = lipgloss.NewStyle().Width(30).Height(50).Border(lipgloss.HiddenBorder(), true)
styles.ColumnInsert = lipgloss.NewStyle().Width(30).Height(50).Border(lipgloss.DoubleBorder(), true).BorderForeground(styles.Active.GetForeground())
return styles
}
func parseColors(config map[string]string) *Styles {
styles := Styles{}
for key, value := range config {
if strings.HasPrefix(key, "color.") {
_, colorValue, _ := strings.Cut(key, ".")
switch colorValue {
case "active":
styles.Active = parseColorString(value)
case "alternate":
styles.Alternate = parseColorString(value)
case "blocked":
styles.Blocked = parseColorString(value)
case "blocking":
styles.Blocking = parseColorString(value)
case "burndown.done":
styles.BurndownDone = parseColorString(value)
case "burndown.pending":
styles.BurndownPending = parseColorString(value)
case "burndown.started":
styles.BurndownStarted = parseColorString(value)
case "calendar.due":
styles.CalendarDue = parseColorString(value)
case "calendar.due.today":
styles.CalendarDueToday = parseColorString(value)
case "calendar.holiday":
styles.CalendarHoliday = parseColorString(value)
case "calendar.overdue":
styles.CalendarOverdue = parseColorString(value)
case "calendar.scheduled":
styles.CalendarScheduled = parseColorString(value)
case "calendar.today":
styles.CalendarToday = parseColorString(value)
case "calendar.weekend":
styles.CalendarWeekend = parseColorString(value)
case "calendar.weeknumber":
styles.CalendarWeeknumber = parseColorString(value)
case "completed":
styles.Completed = parseColorString(value)
case "debug":
styles.Debug = parseColorString(value)
case "deleted":
styles.Deleted = parseColorString(value)
case "due":
styles.Due = parseColorString(value)
case "due.today":
styles.DueToday = parseColorString(value)
case "error":
styles.Error = parseColorString(value)
case "footnote":
styles.Footnote = parseColorString(value)
case "header":
styles.Header = parseColorString(value)
case "history.add":
styles.HistoryAdd = parseColorString(value)
case "history.delete":
styles.HistoryDelete = parseColorString(value)
case "history.done":
styles.HistoryDone = parseColorString(value)
case "label":
styles.Label = parseColorString(value)
case "label.sort":
styles.LabelSort = parseColorString(value)
case "overdue":
styles.Overdue = parseColorString(value)
case "project.none":
styles.ProjectNone = parseColorString(value)
case "recurring":
styles.Recurring = parseColorString(value)
case "scheduled":
styles.Scheduled = parseColorString(value)
case "summary.background":
styles.SummaryBackground = parseColorString(value)
case "summary.bar":
styles.SummaryBar = parseColorString(value)
case "sync.added":
styles.SyncAdded = parseColorString(value)
case "sync.changed":
styles.SyncChanged = parseColorString(value)
case "sync.rejected":
styles.SyncRejected = parseColorString(value)
case "tag.next":
styles.TagNext = parseColorString(value)
case "tag.none":
styles.TagNone = parseColorString(value)
case "tagged":
styles.Tagged = parseColorString(value)
case "uda.priority.H":
styles.UdaPriorityH = parseColorString(value)
case "uda.priority.L":
styles.UdaPriorityL = parseColorString(value)
case "uda.priority.M":
styles.UdaPriorityM = parseColorString(value)
case "undo.after":
styles.UndoAfter = parseColorString(value)
case "undo.before":
styles.UndoBefore = parseColorString(value)
case "until":
styles.Until = parseColorString(value)
case "warning":
styles.Warning = parseColorString(value)
}
}
}
return &styles
}
func parseColorString(color string) lipgloss.Style {
style := lipgloss.NewStyle()
if color == "" {
return style
}
if strings.Contains(color, "on") {
fgbg := strings.Split(color, "on")
fg := strings.TrimSpace(fgbg[0])
bg := strings.TrimSpace(fgbg[1])
if fg != "" {
style = style.Foreground(parseColor(fg))
}
if bg != "" {
style = style.Background(parseColor(bg))
}
} else {
style = style.Foreground(parseColor(strings.TrimSpace(color)))
}
return style
}
func parseColor(color string) lipgloss.Color {
if strings.HasPrefix(color, "rgb") {
return lipgloss.Color(convertRgbToAnsi(strings.TrimPrefix(color, "rgb")))
}
if strings.HasPrefix(color, "color") {
return lipgloss.Color(strings.TrimPrefix(color, "color"))
}
if strings.HasPrefix(color, "gray") {
gray, err := strconv.Atoi(strings.TrimPrefix(color, "gray"))
if err != nil {
slog.Error("Invalid gray color format")
return lipgloss.Color("0")
}
return lipgloss.Color(strconv.Itoa(gray + 232))
}
if ansi, okcolor := colorStrings[color]; okcolor {
return lipgloss.Color(strconv.Itoa(ansi))
}
slog.Error("Invalid color format")
return lipgloss.Color("0")
}
func convertRgbToAnsi(rgbString string) string {
var err error
if len(rgbString) != 3 {
slog.Error("Invalid RGB color format")
return ""
}
r, err := strconv.Atoi(string(rgbString[0]))
if err != nil {
slog.Error("Invalid value for R")
return ""
}
g, err := strconv.Atoi(string(rgbString[1]))
if err != nil {
slog.Error("Invalid value for G")
return ""
}
b, err := strconv.Atoi(string(rgbString[2]))
if err != nil {
slog.Error("Invalid value for B")
return ""
}
return strconv.Itoa(16 + (36 * r) + (6 * g) + b)
}
var colorStrings = map[string]int{
"black": 0,
"red": 1,
"green": 2,
"yellow": 3,
"blue": 4,
"magenta": 5,
"cyan": 6,
"white": 7,
"bright black": 8,
"bright red": 9,
"bright green": 10,
"bright yellow": 11,
"bright blue": 12,
"bright magenta": 13,
"bright cyan": 14,
"bright white": 15,
}

View File

@@ -1,593 +0,0 @@
package table
import (
"strings"
"time"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-runewidth"
"tasksquire/common"
"tasksquire/taskwarrior"
)
// Model defines a state for the table widget.
type Model struct {
common *common.Common
KeyMap KeyMap
cols []Column
rows taskwarrior.Tasks
rowStyles []lipgloss.Style
cursor int
focus bool
styles common.TableStyle
styleFunc StyleFunc
viewport viewport.Model
start int
end int
}
// Row represents one line in the table.
type Row *taskwarrior.Task
// Column defines the table structure.
type Column struct {
Title string
Name string
Width int
MaxWidth int
ContentWidth int
}
// KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which
// is used to render the menu.
type KeyMap struct {
LineUp key.Binding
LineDown key.Binding
PageUp key.Binding
PageDown key.Binding
HalfPageUp key.Binding
HalfPageDown key.Binding
GotoTop key.Binding
GotoBottom key.Binding
}
// ShortHelp implements the KeyMap interface.
func (km KeyMap) ShortHelp() []key.Binding {
return []key.Binding{km.LineUp, km.LineDown}
}
// FullHelp implements the KeyMap interface.
func (km KeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{km.LineUp, km.LineDown, km.GotoTop, km.GotoBottom},
{km.PageUp, km.PageDown, km.HalfPageUp, km.HalfPageDown},
}
}
// DefaultKeyMap returns a default set of keybindings.
func DefaultKeyMap() KeyMap {
const spacebar = " "
return KeyMap{
LineUp: key.NewBinding(
key.WithKeys("up", "k"),
key.WithHelp("↑/k", "up"),
),
LineDown: key.NewBinding(
key.WithKeys("down", "j"),
key.WithHelp("↓/j", "down"),
),
PageUp: key.NewBinding(
key.WithKeys("b", "pgup"),
key.WithHelp("b/pgup", "page up"),
),
PageDown: key.NewBinding(
key.WithKeys("f", "pgdown", spacebar),
key.WithHelp("f/pgdn", "page down"),
),
HalfPageUp: key.NewBinding(
key.WithKeys("u", "ctrl+u"),
key.WithHelp("u", "½ page up"),
),
HalfPageDown: key.NewBinding(
key.WithKeys("d", "ctrl+d"),
key.WithHelp("d", "½ page down"),
),
GotoTop: key.NewBinding(
key.WithKeys("home", "g"),
key.WithHelp("g/home", "go to start"),
),
GotoBottom: key.NewBinding(
key.WithKeys("end", "G"),
key.WithHelp("G/end", "go to end"),
),
}
}
// SetStyles sets the table styles.
func (m *Model) SetStyles(s common.TableStyle) {
m.styles = s
m.UpdateViewport()
}
// Option is used to set options in New. For example:
//
// table := New(WithColumns([]Column{{Title: "ID", Width: 10}}))
type Option func(*Model)
// New creates a new model for the table widget.
func New(com *common.Common, opts ...Option) Model {
m := Model{
common: com,
cursor: 0,
viewport: viewport.New(0, 20),
KeyMap: DefaultKeyMap(),
}
for _, opt := range opts {
opt(&m)
}
m.cols = m.parseColumns(m.cols)
m.rowStyles = m.parseRowStyles(m.rows)
m.UpdateViewport()
return m
}
// TODO: dynamically read rule.precedence.color
func (m *Model) parseRowStyles(rows taskwarrior.Tasks) []lipgloss.Style {
styles := make([]lipgloss.Style, len(rows))
if len(rows) == 0 {
return styles
}
for i, task := range rows {
if task.Status == "deleted" {
styles[i] = m.common.Styles.Deleted.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
continue
}
if task.Status == "completed" {
styles[i] = m.common.Styles.Completed.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
continue
}
if task.Status == "pending" && task.Start != "" {
styles[i] = m.common.Styles.Active.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
continue
}
// TODO: implement keyword
// TODO: implement tag
// TODO: implement project
if !task.GetDate("due").IsZero() && task.GetDate("due").Before(time.Now()) {
styles[i] = m.common.Styles.Overdue.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
continue
}
if task.Scheduled != "" {
styles[i] = m.common.Styles.Scheduled.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
continue
}
if !task.GetDate("due").IsZero() && task.GetDate("due").Truncate(24*time.Hour).Equal(time.Now().Truncate(24*time.Hour)) {
styles[i] = m.common.Styles.DueToday.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
continue
}
if task.Due != "" {
styles[i] = m.common.Styles.Due.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
continue
}
if len(task.Depends) > 0 {
styles[i] = m.common.Styles.Blocked.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
continue
}
// TODO implement blocking
if task.Recur != "" {
styles[i] = m.common.Styles.Recurring.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
continue
}
if len(task.Tags) > 0 {
styles[i] = m.common.Styles.Tagged.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
taskIteration:
for _, tag := range task.Tags {
if tag == "next" {
styles[i] = m.common.Styles.TagNext.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
break taskIteration
}
}
continue
}
// TODO implement uda
styles[i] = m.common.Styles.Base.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
}
return styles
}
func (m *Model) parseColumns(cols []Column) []Column {
if len(cols) == 0 {
return cols
}
for i, col := range cols {
for _, task := range m.rows {
col.ContentWidth = max(col.ContentWidth, lipgloss.Width(task.GetString(col.Name)))
}
cols[i] = col
}
combinedSize := 0
nonZeroWidths := 0
descIndex := -1
for i, col := range cols {
if col.ContentWidth > 0 {
col.Width = max(col.ContentWidth, lipgloss.Width(col.Title))
nonZeroWidths++
}
if !strings.Contains(col.Name, "description") {
combinedSize += col.Width
} else {
descIndex = i
}
cols[i] = col
}
if descIndex >= 0 {
cols[descIndex].Width = m.Width() - combinedSize - nonZeroWidths
}
return cols
}
// WithColumns sets the table columns (headers).
func WithColumns(cols []Column) Option {
return func(m *Model) {
m.cols = cols
}
}
func WithReport(report *taskwarrior.Report) Option {
return func(m *Model) {
columns := make([]Column, len(report.Columns))
for i, col := range report.Columns {
columns[i] = Column{
Title: report.Labels[i],
Name: col,
Width: 0,
}
}
m.cols = columns
}
}
// WithRows sets the table rows (data).
func WithRows(rows taskwarrior.Tasks) Option {
return func(m *Model) {
m.rows = rows
}
}
// WithRows sets the table rows (data).
func WithTasks(rows taskwarrior.Tasks) Option {
return func(m *Model) {
m.rows = rows
}
}
// WithHeight sets the height of the table.
func WithHeight(h int) Option {
return func(m *Model) {
m.viewport.Height = h - lipgloss.Height(m.headersView())
}
}
// WithWidth sets the width of the table.
func WithWidth(w int) Option {
return func(m *Model) {
m.viewport.Width = w
}
}
// WithFocused sets the focus state of the table.
func WithFocused(f bool) Option {
return func(m *Model) {
m.focus = f
}
}
// WithStyles sets the table styles.
func WithStyles(s common.TableStyle) Option {
return func(m *Model) {
m.styles = s
}
}
// WithStyleFunc sets the table style func which can determine a cell style per column, row, and selected state.
func WithStyleFunc(f StyleFunc) Option {
return func(m *Model) {
m.styleFunc = f
}
}
// WithKeyMap sets the key map.
func WithKeyMap(km KeyMap) Option {
return func(m *Model) {
m.KeyMap = km
}
}
// Update is the Bubble Tea update loop.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if !m.focus {
return m, nil
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, m.KeyMap.LineUp):
m.MoveUp(1)
case key.Matches(msg, m.KeyMap.LineDown):
m.MoveDown(1)
case key.Matches(msg, m.KeyMap.PageUp):
m.MoveUp(m.viewport.Height)
case key.Matches(msg, m.KeyMap.PageDown):
m.MoveDown(m.viewport.Height)
case key.Matches(msg, m.KeyMap.HalfPageUp):
m.MoveUp(m.viewport.Height / 2)
case key.Matches(msg, m.KeyMap.HalfPageDown):
m.MoveDown(m.viewport.Height / 2)
case key.Matches(msg, m.KeyMap.LineDown):
m.MoveDown(1)
case key.Matches(msg, m.KeyMap.GotoTop):
m.GotoTop()
case key.Matches(msg, m.KeyMap.GotoBottom):
m.GotoBottom()
}
}
return m, nil
}
// Focused returns the focus state of the table.
func (m Model) Focused() bool {
return m.focus
}
// Focus focuses the table, allowing the user to move around the rows and
// interact.
func (m *Model) Focus() {
m.focus = true
m.UpdateViewport()
}
// Blur blurs the table, preventing selection or movement.
func (m *Model) Blur() {
m.focus = false
m.UpdateViewport()
}
// View renders the component.
func (m Model) View() string {
return m.headersView() + "\n" + m.viewport.View()
}
// UpdateViewport updates the list content based on the previously defined
// columns and rows.
func (m *Model) UpdateViewport() {
renderedRows := make([]string, 0, len(m.rows))
// Render only rows from: m.cursor-m.viewport.Height to: m.cursor+m.viewport.Height
// Constant runtime, independent of number of rows in a table.
// Limits the number of renderedRows to a maximum of 2*m.viewport.Height
if m.cursor >= 0 {
m.start = clamp(m.cursor-m.viewport.Height, 0, m.cursor)
} else {
m.start = 0
}
m.end = clamp(m.cursor+m.viewport.Height, m.cursor, len(m.rows))
for i := m.start; i < m.end; i++ {
renderedRows = append(renderedRows, m.renderRow(i))
}
m.viewport.SetContent(
lipgloss.JoinVertical(lipgloss.Left, renderedRows...),
)
}
// SelectedRow returns the selected row.
// You can cast it to your own implementation.
func (m Model) SelectedRow() Row {
if m.cursor < 0 || m.cursor >= len(m.rows) {
return nil
}
return m.rows[m.cursor]
}
// Rows returns the current rows.
func (m Model) Rows() taskwarrior.Tasks {
return m.rows
}
// Columns returns the current columns.
func (m Model) Columns() []Column {
return m.cols
}
// SetRows sets a new rows state.
func (m *Model) SetRows(r taskwarrior.Tasks) {
m.rows = r
m.UpdateViewport()
}
// SetColumns sets a new columns state.
func (m *Model) SetColumns(c []Column) {
m.cols = c
m.UpdateViewport()
}
// SetWidth sets the width of the viewport of the table.
func (m *Model) SetWidth(w int) {
m.viewport.Width = w
m.UpdateViewport()
}
// SetHeight sets the height of the viewport of the table.
func (m *Model) SetHeight(h int) {
m.viewport.Height = h - lipgloss.Height(m.headersView())
m.UpdateViewport()
}
// Height returns the viewport height of the table.
func (m Model) Height() int {
return m.viewport.Height
}
// Width returns the viewport width of the table.
func (m Model) Width() int {
return m.viewport.Width
}
// Cursor returns the index of the selected row.
func (m Model) Cursor() int {
return m.cursor
}
// SetCursor sets the cursor position in the table.
func (m *Model) SetCursor(n int) {
m.cursor = clamp(n, 0, len(m.rows)-1)
m.UpdateViewport()
}
// MoveUp moves the selection up by any number of rows.
// It can not go above the first row.
func (m *Model) MoveUp(n int) {
m.cursor = clamp(m.cursor-n, 0, len(m.rows)-1)
switch {
case m.start == 0:
m.viewport.SetYOffset(clamp(m.viewport.YOffset, 0, m.cursor))
case m.start < m.viewport.Height:
m.viewport.YOffset = (clamp(clamp(m.viewport.YOffset+n, 0, m.cursor), 0, m.viewport.Height))
case m.viewport.YOffset >= 1:
m.viewport.YOffset = clamp(m.viewport.YOffset+n, 1, m.viewport.Height)
}
m.UpdateViewport()
}
// MoveDown moves the selection down by any number of rows.
// It can not go below the last row.
func (m *Model) MoveDown(n int) {
m.cursor = clamp(m.cursor+n, 0, len(m.rows)-1)
m.UpdateViewport()
switch {
case m.end == len(m.rows) && m.viewport.YOffset > 0:
m.viewport.SetYOffset(clamp(m.viewport.YOffset-n, 1, m.viewport.Height))
case m.cursor > (m.end-m.start)/2 && m.viewport.YOffset > 0:
m.viewport.SetYOffset(clamp(m.viewport.YOffset-n, 1, m.cursor))
case m.viewport.YOffset > 1:
case m.cursor > m.viewport.YOffset+m.viewport.Height-1:
m.viewport.SetYOffset(clamp(m.viewport.YOffset+1, 0, 1))
}
}
// GotoTop moves the selection to the first row.
func (m *Model) GotoTop() {
m.MoveUp(m.cursor)
}
// GotoBottom moves the selection to the last row.
func (m *Model) GotoBottom() {
m.MoveDown(len(m.rows))
}
// FromValues create the table rows from a simple string. It uses `\n` by
// default for getting all the rows and the given separator for the fields on
// each row.
// func (m *Model) FromValues(value, separator string) {
// rows := []Row{}
// for _, line := range strings.Split(value, "\n") {
// r := Row{}
// for _, field := range strings.Split(line, separator) {
// r = append(r, field)
// }
// rows = append(rows, r)
// }
// m.SetRows(rows)
// }
// StyleFunc is a function that can be used to customize the style of a table cell based on the row and column index.
type StyleFunc func(row, col int, value string) lipgloss.Style
func (m Model) headersView() string {
var s = make([]string, 0, len(m.cols))
for _, col := range m.cols {
if col.Width <= 0 {
continue
}
style := lipgloss.NewStyle().Width(col.Width).MaxWidth(col.Width).Inline(true)
renderedCell := style.Render(runewidth.Truncate(col.Title, col.Width, "…"))
s = append(s, m.styles.Header.Render(renderedCell))
}
return lipgloss.JoinHorizontal(lipgloss.Left, s...)
}
func (m *Model) renderRow(r int) string {
var s = make([]string, 0, len(m.cols))
for i, col := range m.cols {
// for i, task := range m.rows[r] {
if m.cols[i].Width <= 0 {
continue
}
var cellStyle lipgloss.Style
// if m.styleFunc != nil {
// cellStyle = m.styleFunc(r, i, m.rows[r].GetString(col.Name))
// if r == m.cursor {
// cellStyle.Inherit(m.styles.Selected)
// }
// } else {
cellStyle = m.rowStyles[r]
// }
if r == m.cursor {
cellStyle = cellStyle.Inherit(m.styles.Selected)
}
style := lipgloss.NewStyle().Width(m.cols[i].Width).MaxWidth(m.cols[i].Width).Inline(true)
renderedCell := cellStyle.Render(style.Render(runewidth.Truncate(m.rows[r].GetString(col.Name), m.cols[i].Width, "…")))
s = append(s, renderedCell)
}
row := lipgloss.JoinHorizontal(lipgloss.Left, s...)
if r == m.cursor {
return m.styles.Selected.Render(row)
}
return row
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func clamp(v, low, high int) int {
return min(max(v, low), high)
}

63
flake.lock generated
View File

@@ -1,58 +1,59 @@
{
"nodes": {
"flake-utils": {
"flake-parts": {
"inputs": {
"systems": "systems"
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"lastModified": 1769996383,
"narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "57928607ea566b5db3ad13af0e57e921e6b12381",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1715787315,
"narHash": "sha256-cYApT0NXJfqBkKcci7D9Kr4CBYZKOQKDYA23q8XNuWg=",
"owner": "NixOS",
"lastModified": 1771848320,
"narHash": "sha256-0MAd+0mun3K/Ns8JATeHT1sX28faLII5hVLq0L3BdZU=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "33d1e753c82ffc557b4a585c77de43d4c922ebb5",
"rev": "2fc6539b481e1d2569f25f8799236694180c0993",
"type": "github"
},
"original": {
"id": "nixpkgs",
"owner": "nixos",
"ref": "nixos-unstable",
"type": "indirect"
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1769909678,
"narHash": "sha256-cBEymOf4/o3FD5AZnzC3J9hLbiZ+QDT/KDuyHXVJOpM=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "72716169fe93074c333e8d0173151350670b824c",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixpkgs.lib",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"flake-parts": "flake-parts",
"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",

View File

@@ -2,38 +2,55 @@
description = "Tasksquire";
inputs = {
nixpkgs.url = "nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-parts.url = "github:hercules-ci/flake-parts";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs {
inherit system;
outputs = inputs@{ self, nixpkgs, flake-parts, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin" ];
perSystem = { config, self', inputs', pkgs, system, ... }: {
packages.tasksquire = pkgs.buildGoModule {
pname = "tasksquire";
version = "0.1.0";
src = ./.;
vendorHash = "sha256-fDzQuKBZPkOATMMnYcFv/aJP62XDhL9LjM/UYre9JQ4=";
ldflags = [ "-s" "-w" ];
nativeBuildInputs = with pkgs; [
taskwarrior3
timewarrior
];
meta = with pkgs.lib; {
description = "A Terminal User Interface (TUI) for Taskwarrior";
mainProgram = "tasksquire";
};
};
buildDeps = with pkgs; [
go_1_22
gcc
];
# Set the default package
packages.default = self'.packages.tasksquire;
devDeps = with pkgs; buildDeps ++ [
gotools
golangci-lint
gopls
go-outline
gopkgs
go-tools
gotests
delve
];
in
{
devShell = pkgs.mkShell {
buildInputs = devDeps;
CGO_CFLAGS="-O";
# Development shell
devShells.default = pkgs.mkShell {
inputsFrom = [ self'.packages.tasksquire ];
buildInputs = with pkgs; [
go
gcc
gotools
golangci-lint
gopls
go-outline
gopkgs
go-tools
gotests
delve
];
CGO_CFLAGS = "-O";
};
});
};
};
}

46
go.mod
View File

@@ -1,32 +1,28 @@
module tasksquire
go 1.22.2
go 1.25
toolchain go1.25.7
require (
github.com/charmbracelet/bubbles v0.18.0
github.com/charmbracelet/bubbletea v0.26.1
github.com/charmbracelet/huh v0.3.0
github.com/charmbracelet/lipgloss v0.10.1-0.20240506202754-3ee5dcab73cb
github.com/mattn/go-runewidth v0.0.15
golang.org/x/term v0.20.0
)
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/x/exp/term v0.0.0-20240506152644-8135bef4e495 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // 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/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
charm.land/bubbles/v2 v2.0.0 // indirect
charm.land/bubbletea/v2 v2.0.0 // indirect
charm.land/lipgloss/v2 v2.0.0 // indirect
github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-runewidth v0.0.20 // 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
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/term v0.40.0 // indirect
)

95
go.sum
View File

@@ -1,53 +1,50 @@
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.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
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/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/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/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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
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/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
charm.land/bubbletea/v2 v2.0.0 h1:p0d6CtWyJXJ9GfzMpUUqbP/XUUhhlk06+vCKWmox1wQ=
charm.land/bubbletea/v2 v2.0.0/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo=
charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
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.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y=
github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
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.6.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.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
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=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=

View File

@@ -5,7 +5,8 @@ import (
"log/slog"
"os"
"tasksquire/taskwarrior"
"tasksquire/internal/taskwarrior"
"tasksquire/internal/timewarrior"
"golang.org/x/term"
)
@@ -13,20 +14,24 @@ import (
type Common struct {
Ctx context.Context
TW taskwarrior.TaskWarrior
TimeW timewarrior.TimeWarrior
Keymap *Keymap
Styles *Styles
Udas []taskwarrior.Uda
pageStack *Stack[Component]
width int
height int
}
func NewCommon(ctx context.Context, tw taskwarrior.TaskWarrior) *Common {
func NewCommon(ctx context.Context, tw taskwarrior.TaskWarrior, timeW timewarrior.TimeWarrior) *Common {
return &Common{
Ctx: ctx,
TW: tw,
TimeW: timeW,
Keymap: NewKeymap(),
Styles: NewStyles(tw.GetConfig()),
Udas: tw.GetUdas(),
pageStack: NewStack[Component](),
}
@@ -52,5 +57,15 @@ func (c *Common) PushPage(page Component) {
}
func (c *Common) PopPage() (Component, error) {
return c.pageStack.Pop()
component, err := c.pageStack.Pop()
if err != nil {
return nil, err
}
component.SetSize(c.width, c.height)
return component, nil
}
func (c *Common) HasSubpages() bool {
return !c.pageStack.IsEmpty()
}

View File

@@ -1,6 +1,6 @@
package common
import tea "github.com/charmbracelet/bubbletea"
import tea "charm.land/bubbletea/v2"
type Component interface {
tea.Model

View File

@@ -1,30 +1,39 @@
package common
import (
"github.com/charmbracelet/bubbles/key"
"charm.land/bubbles/v2/key"
)
// Keymap is a collection of key bindings.
type Keymap struct {
Quit key.Binding
Back key.Binding
Ok key.Binding
Delete key.Binding
Input key.Binding
Add key.Binding
Edit key.Binding
Up key.Binding
Down key.Binding
Left key.Binding
Right key.Binding
SetReport key.Binding
SetContext key.Binding
SetProject key.Binding
Select key.Binding
Insert key.Binding
Tag key.Binding
Undo key.Binding
StartStop key.Binding
Quit key.Binding
Back key.Binding
Ok key.Binding
Delete key.Binding
Input key.Binding
Add key.Binding
Edit key.Binding
Up key.Binding
Down key.Binding
Left key.Binding
Right key.Binding
Next key.Binding
Prev key.Binding
NextPage key.Binding
PrevPage key.Binding
SetReport key.Binding
SetContext key.Binding
SetProject key.Binding
PickProjectTask key.Binding
Select key.Binding
Insert key.Binding
Tag key.Binding
Undo key.Binding
Fill key.Binding
StartStop key.Binding
Join key.Binding
ViewDetails key.Binding
Subtask key.Binding
}
// TODO: use config values for key bindings
@@ -86,6 +95,26 @@ func NewKeymap() *Keymap {
key.WithHelp("→/l", "Right"),
),
Next: key.NewBinding(
key.WithKeys("tab"),
key.WithHelp("tab", "Next"),
),
Prev: key.NewBinding(
key.WithKeys("shift+tab"),
key.WithHelp("shift+tab", "Previous"),
),
NextPage: key.NewBinding(
key.WithKeys("]", "L"),
key.WithHelp("]/L", "Next page"),
),
PrevPage: key.NewBinding(
key.WithKeys("[", "H"),
key.WithHelp("[/H", "Previous page"),
),
SetReport: key.NewBinding(
key.WithKeys("r"),
key.WithHelp("r", "Set report"),
@@ -101,9 +130,14 @@ func NewKeymap() *Keymap {
key.WithHelp("p", "Set project"),
),
PickProjectTask: key.NewBinding(
key.WithKeys("P"),
key.WithHelp("P", "Pick project task"),
),
Select: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "Select"),
key.WithKeys(" "),
key.WithHelp("space", "Select"),
),
Insert: key.NewBinding(
@@ -121,9 +155,29 @@ func NewKeymap() *Keymap {
key.WithHelp("undo", "Undo"),
),
Fill: key.NewBinding(
key.WithKeys("f"),
key.WithHelp("fill", "Fill gaps"),
),
StartStop: key.NewBinding(
key.WithKeys("s"),
key.WithHelp("start/stop", "Start/Stop"),
),
Join: key.NewBinding(
key.WithKeys("J"),
key.WithHelp("J", "Join with previous"),
),
ViewDetails: key.NewBinding(
key.WithKeys("v"),
key.WithHelp("v", "view details"),
),
Subtask: key.NewBinding(
key.WithKeys("S"),
key.WithHelp("S", "Create subtask"),
),
}
}

View File

@@ -0,0 +1,18 @@
package common
import (
"tasksquire/internal/taskwarrior"
"time"
tea "charm.land/bubbletea/v2"
)
type TaskMsg taskwarrior.Tasks
type TickMsg time.Time
func DoTick() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return TickMsg(t)
})
}

View File

@@ -38,3 +38,10 @@ func (s *Stack[T]) Pop() (T, error) {
return item, nil
}
func (s *Stack[T]) IsEmpty() bool {
s.mutex.Lock()
defer s.mutex.Unlock()
return len(s.items) == 0
}

234
internal/common/styles.go Normal file
View File

@@ -0,0 +1,234 @@
package common
import (
"log/slog"
"strconv"
"strings"
"image/color"
"tasksquire/internal/taskwarrior"
"charm.land/lipgloss/v2"
)
type TableStyle struct {
Header lipgloss.Style
Cell lipgloss.Style
Selected lipgloss.Style
}
type Palette struct {
Primary lipgloss.Style
Secondary lipgloss.Style
Accent lipgloss.Style
Muted lipgloss.Style
Border lipgloss.Style
Background lipgloss.Style
Text lipgloss.Style
}
type Styles struct {
Colors map[string]*lipgloss.Style
Palette Palette
Base lipgloss.Style
// Form *huh.Theme
TableStyle TableStyle
Tab lipgloss.Style
ActiveTab lipgloss.Style
TabBar lipgloss.Style
ColumnFocused lipgloss.Style
ColumnBlurred lipgloss.Style
ColumnInsert lipgloss.Style
}
func NewStyles(config *taskwarrior.TWConfig) *Styles {
styles := Styles{}
colors := make(map[string]*lipgloss.Style)
for key, value := range config.GetConfig() {
if strings.HasPrefix(key, "color.") {
_, color, _ := strings.Cut(key, ".")
colors[color] = parseColorString(value)
}
}
styles.Colors = colors
// Initialize Palette (Iceberg Light)
styles.Palette.Primary = lipgloss.NewStyle().Foreground(lipgloss.Color("#2d539e")) // Blue
styles.Palette.Secondary = lipgloss.NewStyle().Foreground(lipgloss.Color("#7759b4")) // Purple
styles.Palette.Accent = lipgloss.NewStyle().Foreground(lipgloss.Color("#c57339")) // Orange
styles.Palette.Muted = lipgloss.NewStyle().Foreground(lipgloss.Color("#8389a3")) // Grey
styles.Palette.Border = lipgloss.NewStyle().Foreground(lipgloss.Color("#cad0de")) // Light Grey Border
styles.Palette.Background = lipgloss.NewStyle().Background(lipgloss.Color("#e8e9ec")) // Light Background
styles.Palette.Text = lipgloss.NewStyle().Foreground(lipgloss.Color("#33374c")) // Dark Text
// Override from config if available (example mapping)
if s, ok := styles.Colors["primary"]; ok {
styles.Palette.Primary = *s
}
if s, ok := styles.Colors["secondary"]; ok {
styles.Palette.Secondary = *s
}
if s, ok := styles.Colors["active"]; ok {
styles.Palette.Accent = *s
}
styles.Base = lipgloss.NewStyle().Foreground(styles.Palette.Text.GetForeground())
styles.TableStyle = TableStyle{
// Header: lipgloss.NewStyle().Bold(true).Padding(0, 1).BorderBottom(true),
Cell: lipgloss.NewStyle().Padding(0, 1, 0, 0),
Header: lipgloss.NewStyle().Bold(true).Padding(0, 1, 0, 0).Underline(true).Foreground(styles.Palette.Primary.GetForeground()),
Selected: lipgloss.NewStyle().Bold(true).Reverse(true).Foreground(styles.Palette.Accent.GetForeground()),
}
// formTheme := huh.ThemeBase()
// formTheme.Focused.Title = formTheme.Focused.Title.Bold(true).Foreground(styles.Palette.Primary.GetForeground())
// formTheme.Focused.SelectSelector = formTheme.Focused.SelectSelector.SetString("→ ").Foreground(styles.Palette.Accent.GetForeground())
// formTheme.Focused.SelectedOption = formTheme.Focused.SelectedOption.Bold(true).Foreground(styles.Palette.Accent.GetForeground())
// formTheme.Focused.MultiSelectSelector = formTheme.Focused.MultiSelectSelector.SetString("→ ").Foreground(styles.Palette.Accent.GetForeground())
// formTheme.Focused.SelectedPrefix = formTheme.Focused.SelectedPrefix.SetString("✓ ").Foreground(styles.Palette.Accent.GetForeground())
// formTheme.Focused.UnselectedPrefix = formTheme.Focused.SelectedPrefix.SetString("• ")
// formTheme.Blurred.Title = formTheme.Blurred.Title.Bold(true).Foreground(styles.Palette.Muted.GetForeground())
// formTheme.Blurred.SelectSelector = formTheme.Blurred.SelectSelector.SetString(" ")
// formTheme.Blurred.SelectedOption = formTheme.Blurred.SelectedOption.Bold(true)
// formTheme.Blurred.MultiSelectSelector = formTheme.Blurred.MultiSelectSelector.SetString(" ")
// formTheme.Blurred.SelectedPrefix = formTheme.Blurred.SelectedPrefix.SetString("✓ ")
// formTheme.Blurred.UnselectedPrefix = formTheme.Blurred.SelectedPrefix.SetString("• ")
// styles.Form = formTheme
styles.Tab = lipgloss.NewStyle().
Padding(0, 1).
Foreground(styles.Palette.Muted.GetForeground())
styles.ActiveTab = styles.Tab.
Foreground(styles.Palette.Primary.GetForeground()).
Bold(true)
styles.TabBar = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, true, false).
BorderForeground(styles.Palette.Border.GetForeground()).
MarginBottom(1)
styles.ColumnFocused = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1).BorderForeground(styles.Palette.Primary.GetForeground())
styles.ColumnBlurred = lipgloss.NewStyle().Border(lipgloss.HiddenBorder(), true).Padding(1).BorderForeground(styles.Palette.Border.GetForeground())
styles.ColumnInsert = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1).BorderForeground(styles.Palette.Accent.GetForeground())
return &styles
}
func (s *Styles) GetModalSize(width, height int) (int, int) {
modalWidth := 60
if width < 64 {
modalWidth = width - 4
}
modalHeight := 20
if height < 24 {
modalHeight = height - 4
}
return modalWidth, modalHeight
}
func parseColorString(color string) *lipgloss.Style {
if color == "" {
return nil
}
style := lipgloss.NewStyle()
if strings.Contains(color, "on") {
fgbg := strings.Split(color, "on")
fg := strings.TrimSpace(fgbg[0])
bg := strings.TrimSpace(fgbg[1])
if fg != "" {
style = style.Foreground(parseColor(fg))
}
if bg != "" {
style = style.Background(parseColor(bg))
}
} else {
style = style.Foreground(parseColor(strings.TrimSpace(color)))
}
return &style
}
func parseColor(color string) color.Color {
if strings.HasPrefix(color, "rgb") {
return lipgloss.Color(convertRgbToAnsi(strings.TrimPrefix(color, "rgb")))
}
if strings.HasPrefix(color, "color") {
return lipgloss.Color(strings.TrimPrefix(color, "color"))
}
if strings.HasPrefix(color, "gray") {
gray, err := strconv.Atoi(strings.TrimPrefix(color, "gray"))
if err != nil {
slog.Error("Invalid gray color format")
return lipgloss.Color("0")
}
return lipgloss.Color(strconv.Itoa(gray + 232))
}
if ansi, okcolor := colorStrings[color]; okcolor {
return lipgloss.Color(strconv.Itoa(ansi))
}
slog.Error("Invalid color format")
return lipgloss.Color("0")
}
func convertRgbToAnsi(rgbString string) string {
var err error
if len(rgbString) != 3 {
slog.Error("Invalid RGB color format")
return ""
}
r, err := strconv.Atoi(string(rgbString[0]))
if err != nil {
slog.Error("Invalid value for R")
return ""
}
g, err := strconv.Atoi(string(rgbString[1]))
if err != nil {
slog.Error("Invalid value for G")
return ""
}
b, err := strconv.Atoi(string(rgbString[2]))
if err != nil {
slog.Error("Invalid value for B")
return ""
}
return strconv.Itoa(16 + (36 * r) + (6 * g) + b)
}
var colorStrings = map[string]int{
"black": 0,
"red": 1,
"green": 2,
"yellow": 3,
"blue": 4,
"magenta": 5,
"cyan": 6,
"white": 7,
"bright black": 8,
"bright red": 9,
"bright green": 10,
"bright yellow": 11,
"bright blue": 12,
"bright magenta": 13,
"bright cyan": 14,
"bright white": 15,
}

85
internal/common/sync.go Normal file
View File

@@ -0,0 +1,85 @@
package common
import (
"log/slog"
"tasksquire/internal/taskwarrior"
"tasksquire/internal/timewarrior"
)
// FindTaskByUUID queries Taskwarrior for a task with the given UUID.
// Returns nil if not found.
func FindTaskByUUID(tw taskwarrior.TaskWarrior, uuid string) *taskwarrior.Task {
if uuid == "" {
return nil
}
// Use empty report to query by UUID filter
report := &taskwarrior.Report{Name: ""}
tasks := tw.GetTasks(report, "uuid:"+uuid)
if len(tasks) > 0 {
return tasks[0]
}
return nil
}
// SyncIntervalToTask synchronizes a Timewarrior interval's state to the corresponding Taskwarrior task.
// Action should be "start" or "stop".
// This function is idempotent and handles edge cases gracefully.
func SyncIntervalToTask(interval *timewarrior.Interval, tw taskwarrior.TaskWarrior, action string) {
if interval == nil {
return
}
// Extract UUID from interval tags
uuid := timewarrior.ExtractUUID(interval.Tags)
if uuid == "" {
slog.Debug("Interval has no UUID tag, skipping task sync",
"intervalID", interval.ID)
return
}
// Find corresponding task
task := FindTaskByUUID(tw, uuid)
if task == nil {
slog.Warn("Task not found for UUID, skipping sync",
"uuid", uuid)
return
}
// Perform sync action
switch action {
case "start":
// Start task if it's pending (idempotent - taskwarrior handles already-started tasks)
if task.Status == "pending" {
slog.Info("Starting Taskwarrior task from interval",
"uuid", uuid,
"description", task.Description,
"alreadyStarted", task.Start != "")
tw.StartTask(task)
} else {
slog.Debug("Task not pending, skipping start",
"uuid", uuid,
"status", task.Status)
}
case "stop":
// Only stop if task is pending and currently started
if task.Status == "pending" && task.Start != "" {
slog.Info("Stopping Taskwarrior task from interval",
"uuid", uuid,
"description", task.Description)
tw.StopTask(task)
} else {
slog.Debug("Task not started or not pending, skipping stop",
"uuid", uuid,
"status", task.Status,
"hasStart", task.Start != "")
}
default:
slog.Error("Unknown sync action", "action", action)
}
}

View File

@@ -29,7 +29,7 @@ type Task struct {
Depends []string `json:"depends,omitempty"`
DependsIds string `json:"-"`
Urgency float32 `json:"urgency,omitempty"`
Parent string `json:"parent,omitempty"`
Parent string `json:"parenttask,omitempty"`
Due string `json:"due,omitempty"`
Wait string `json:"wait,omitempty"`
Scheduled string `json:"scheduled,omitempty"`
@@ -125,7 +125,7 @@ func (t *Task) GetString(fieldWFormat string) string {
}
return strings.Join(t.Tags, " ")
case "parent":
case "parenttask":
if format == "short" {
return t.Parent[:8]
}
@@ -178,7 +178,7 @@ func (t *Task) GetString(fieldWFormat string) string {
return t.Recur
default:
slog.Error(fmt.Sprintf("Field not implemented: %s", field))
slog.Error("Field not implemented", "field", field)
return ""
}
}
@@ -214,7 +214,7 @@ func formatDate(date string, format string) string {
dtformat := "20060102T150405Z"
dt, err := time.Parse(dtformat, date)
if err != nil {
slog.Error("Failed to parse time:", err)
slog.Error("Failed to parse time", "error", err)
return ""
}
@@ -238,7 +238,7 @@ func formatDate(date string, format string) string {
case "countdown":
return parseCountdown(time.Since(dt))
default:
slog.Error(fmt.Sprintf("Date format not implemented: %s", format))
slog.Error("Date format not implemented", "format", format)
return ""
}
}

View File

@@ -0,0 +1,445 @@
package tasktable
import (
taskw "tasksquire/internal/taskwarrior"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
)
// Model defines a state for the table widget.
type Model struct {
KeyMap KeyMap
Help help.Model
cols []Column
rows []Row
cursor int
focus bool
styles Styles
viewport viewport.Model
start int
end int
}
// Row represents one line in the table.
type Row struct {
task taskw.Task
style lipgloss.Style
}
// Column defines the table structure.
type Column struct {
Title string
Name string
Width int
}
// KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which
// is used to render the help menu.
type KeyMap struct {
LineUp key.Binding
LineDown key.Binding
PageUp key.Binding
PageDown key.Binding
HalfPageUp key.Binding
HalfPageDown key.Binding
GotoTop key.Binding
GotoBottom key.Binding
}
// ShortHelp implements the KeyMap interface.
func (km KeyMap) ShortHelp() []key.Binding {
return []key.Binding{km.LineUp, km.LineDown}
}
// FullHelp implements the KeyMap interface.
func (km KeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{km.LineUp, km.LineDown, km.GotoTop, km.GotoBottom},
{km.PageUp, km.PageDown, km.HalfPageUp, km.HalfPageDown},
}
}
// DefaultKeyMap returns a default set of keybindings.
func DefaultKeyMap() KeyMap {
return KeyMap{
LineUp: key.NewBinding(
key.WithKeys("up", "k"),
key.WithHelp("↑/k", "up"),
),
LineDown: key.NewBinding(
key.WithKeys("down", "j"),
key.WithHelp("↓/j", "down"),
),
PageUp: key.NewBinding(
key.WithKeys("b", "pgup"),
key.WithHelp("b/pgup", "page up"),
),
PageDown: key.NewBinding(
key.WithKeys("f", "pgdown", "space"),
key.WithHelp("f/pgdn", "page down"),
),
HalfPageUp: key.NewBinding(
key.WithKeys("u", "ctrl+u"),
key.WithHelp("u", "½ page up"),
),
HalfPageDown: key.NewBinding(
key.WithKeys("d", "ctrl+d"),
key.WithHelp("d", "½ page down"),
),
GotoTop: key.NewBinding(
key.WithKeys("home", "g"),
key.WithHelp("g/home", "go to start"),
),
GotoBottom: key.NewBinding(
key.WithKeys("end", "G"),
key.WithHelp("G/end", "go to end"),
),
}
}
// Styles contains style definitions for this list component. By default, these
// values are generated by DefaultStyles.
type Styles struct {
Header lipgloss.Style
Cell lipgloss.Style
Selected lipgloss.Style
}
// DefaultStyles returns a set of default style definitions for this table.
func DefaultStyles() Styles {
return Styles{
Selected: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")),
Header: lipgloss.NewStyle().Bold(true).Padding(0, 1),
Cell: lipgloss.NewStyle().Padding(0, 1),
}
}
// SetStyles sets the table styles.
func (m *Model) SetStyles(s Styles) {
m.styles = s
m.UpdateViewport()
}
// Option is used to set options in New. For example:
//
// table := New(WithColumns([]Column{{Title: "ID", Width: 10}}))
type Option func(*Model)
// New creates a new model for the table widget.
func New(opts ...Option) Model {
m := Model{
cursor: 0,
viewport: viewport.New(viewport.WithHeight(20)), //nolint:mnd
KeyMap: DefaultKeyMap(),
Help: help.New(),
styles: DefaultStyles(),
}
for _, opt := range opts {
opt(&m)
}
m.UpdateViewport()
return m
}
// WithColumns sets the table columns (headers).
func WithColumns(cols []Column) Option {
return func(m *Model) {
m.cols = cols
}
}
// WithRows sets the table rows (data).
func WithRows(rows []Row) Option {
return func(m *Model) {
m.rows = rows
}
}
// WithHeight sets the height of the table.
func WithHeight(h int) Option {
return func(m *Model) {
m.viewport.SetHeight(h - lipgloss.Height(m.headersView()))
}
}
// WithWidth sets the width of the table.
func WithWidth(w int) Option {
return func(m *Model) {
m.viewport.SetWidth(w)
}
}
// WithFocused sets the focus state of the table.
func WithFocused(f bool) Option {
return func(m *Model) {
m.focus = f
}
}
// WithStyles sets the table styles.
func WithStyles(s Styles) Option {
return func(m *Model) {
m.styles = s
}
}
// WithKeyMap sets the key map.
func WithKeyMap(km KeyMap) Option {
return func(m *Model) {
m.KeyMap = km
}
}
// Update is the Bubble Tea update loop.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if !m.focus {
return m, nil
}
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch {
case key.Matches(msg, m.KeyMap.LineUp):
m.MoveUp(1)
case key.Matches(msg, m.KeyMap.LineDown):
m.MoveDown(1)
case key.Matches(msg, m.KeyMap.PageUp):
m.MoveUp(m.viewport.Height())
case key.Matches(msg, m.KeyMap.PageDown):
m.MoveDown(m.viewport.Height())
case key.Matches(msg, m.KeyMap.HalfPageUp):
m.MoveUp(m.viewport.Height() / 2) //nolint:mnd
case key.Matches(msg, m.KeyMap.HalfPageDown):
m.MoveDown(m.viewport.Height() / 2) //nolint:mnd
case key.Matches(msg, m.KeyMap.GotoTop):
m.GotoTop()
case key.Matches(msg, m.KeyMap.GotoBottom):
m.GotoBottom()
}
}
return m, nil
}
// Focused returns the focus state of the table.
func (m Model) Focused() bool {
return m.focus
}
// Focus focuses the table, allowing the user to move around the rows and
// interact.
func (m *Model) Focus() {
m.focus = true
m.UpdateViewport()
}
// Blur blurs the table, preventing selection or movement.
func (m *Model) Blur() {
m.focus = false
m.UpdateViewport()
}
// View renders the component.
func (m Model) View() string {
return m.headersView() + "\n" + m.viewport.View()
}
// HelpView is a helper method for rendering the help menu from the keymap.
// Note that this view is not rendered by default and you must call it
// manually in your application, where applicable.
func (m Model) HelpView() string {
return m.Help.View(m.KeyMap)
}
// UpdateViewport updates the list content based on the previously defined
// columns and rows.
func (m *Model) UpdateViewport() {
renderedRows := make([]string, 0, len(m.rows))
// Render only rows from: m.cursor-m.viewport.Height to: m.cursor+m.viewport.Height
// Constant runtime, independent of number of rows in a table.
// Limits the number of renderedRows to a maximum of 2*m.viewport.Height
if m.cursor >= 0 {
m.start = clamp(m.cursor-m.viewport.Height(), 0, m.cursor)
} else {
m.start = 0
}
m.end = clamp(m.cursor+m.viewport.Height(), m.cursor, len(m.rows))
for i := m.start; i < m.end; i++ {
renderedRows = append(renderedRows, m.renderRow(i))
}
m.viewport.SetContent(
lipgloss.JoinVertical(lipgloss.Left, renderedRows...),
)
}
// SelectedRow returns the selected row.
// You can cast it to your own implementation.
func (m Model) SelectedRow() Row {
if m.cursor < 0 || m.cursor >= len(m.rows) {
return Row{}
}
return m.rows[m.cursor]
}
// Rows returns the current rows.
func (m Model) Rows() []Row {
return m.rows
}
// Columns returns the current columns.
func (m Model) Columns() []Column {
return m.cols
}
// SetRows sets a new rows state.
func (m *Model) SetRows(r []Row) {
m.rows = r
if m.cursor > len(m.rows)-1 {
m.cursor = len(m.rows) - 1
}
m.UpdateViewport()
}
// SetColumns sets a new columns state.
func (m *Model) SetColumns(c []Column) {
m.cols = c
m.UpdateViewport()
}
// SetWidth sets the width of the viewport of the table.
func (m *Model) SetWidth(w int) {
m.viewport.SetWidth(w)
m.UpdateViewport()
}
// SetHeight sets the height of the viewport of the table.
func (m *Model) SetHeight(h int) {
m.viewport.SetHeight(h - lipgloss.Height(m.headersView()))
m.UpdateViewport()
}
// Height returns the viewport height of the table.
func (m Model) Height() int {
return m.viewport.Height()
}
// Width returns the viewport width of the table.
func (m Model) Width() int {
return m.viewport.Width()
}
// Cursor returns the index of the selected row.
func (m Model) Cursor() int {
return m.cursor
}
// SetCursor sets the cursor position in the table.
func (m *Model) SetCursor(n int) {
m.cursor = clamp(n, 0, len(m.rows)-1)
m.UpdateViewport()
}
// MoveUp moves the selection up by any number of rows.
// It can not go above the first row.
func (m *Model) MoveUp(n int) {
m.cursor = clamp(m.cursor-n, 0, len(m.rows)-1)
offset := m.viewport.YOffset()
switch {
case m.start == 0:
offset = clamp(offset, 0, m.cursor)
case m.start < m.viewport.Height():
offset = clamp(clamp(offset+n, 0, m.cursor), 0, m.viewport.Height())
case offset >= 1:
offset = clamp(offset+n, 1, m.viewport.Height())
}
m.viewport.SetYOffset(offset)
m.UpdateViewport()
}
// MoveDown moves the selection down by any number of rows.
// It can not go below the last row.
func (m *Model) MoveDown(n int) {
m.cursor = clamp(m.cursor+n, 0, len(m.rows)-1)
m.UpdateViewport()
offset := m.viewport.YOffset()
switch {
case m.end == len(m.rows) && offset > 0:
offset = clamp(offset-n, 1, m.viewport.Height())
case m.cursor > (m.end-m.start)/2 && offset > 0:
offset = clamp(offset-n, 1, m.cursor)
case offset > 1:
case m.cursor > offset+m.viewport.Height()-1:
offset = clamp(offset+1, 0, 1)
}
m.viewport.SetYOffset(offset)
}
// GotoTop moves the selection to the first row.
func (m *Model) GotoTop() {
m.MoveUp(m.cursor)
}
// GotoBottom moves the selection to the last row.
func (m *Model) GotoBottom() {
m.MoveDown(len(m.rows))
}
func (m Model) headersView() string {
s := make([]string, 0, len(m.cols))
for _, col := range m.cols {
if col.Width <= 0 {
continue
}
style := lipgloss.NewStyle().Width(col.Width).MaxWidth(col.Width).Inline(true)
renderedCell := style.Render(ansi.Truncate(col.Title, col.Width, "…"))
s = append(s, m.styles.Header.Render(renderedCell))
}
return lipgloss.JoinHorizontal(lipgloss.Top, s...)
}
func (m *Model) renderRow(r int) string {
s := make([]string, 0, len(m.cols))
for i, col := range m.cols {
if m.cols[i].Width <= 0 {
continue
}
cellStyle := m.rows[r].style
if r == m.cursor {
cellStyle = cellStyle.Inherit(m.styles.Selected)
}
style := lipgloss.NewStyle().Width(m.cols[i].Width).MaxWidth(m.cols[i].Width).Inline(true)
renderedCell := cellStyle.Render(style.Render(ansi.Truncate(m.rows[r].task.GetString(col.Name), m.cols[i].Width, "…")))
s = append(s, renderedCell)
}
row := lipgloss.JoinHorizontal(lipgloss.Top, s...)
if r == m.cursor {
return m.styles.Selected.Render(row)
}
return row
}
func clamp(v, low, high int) int {
return min(max(v, low), high)
}

112
internal/pages/main.go Normal file
View File

@@ -0,0 +1,112 @@
package pages
import (
"tasksquire/internal/common"
tea "charm.land/bubbletea/v2"
// "charm.land/bubbles/v2/key"
"charm.land/lipgloss/v2"
)
type MainPage struct {
common *common.Common
activePage common.Component
taskPage common.Component
timePage common.Component
currentTab int
width int
height int
}
func NewMainPage(common *common.Common) *MainPage {
m := &MainPage{
common: common,
}
m.taskPage = NewTaskPage(common, common.TW.GetReport(common.TW.GetConfig().Get("uda.tasksquire.report.default")))
// m.timePage = NewTimePage(common)
//
m.activePage = m.taskPage
m.currentTab = 0
return m
}
func (m *MainPage) Init() tea.Cmd {
// return tea.Batch(m.taskPage.Init(), m.timePage.Init())
return tea.Batch()
}
func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
// switch msg := msg.(type) {
// case tea.WindowSizeMsg:
// m.width = msg.Width
// m.height = msg.Height
// m.common.SetSize(msg.Width, msg.Height)
//
// tabHeight := lipgloss.Height(m.renderTabBar())
// contentHeight := msg.Height - tabHeight
// if contentHeight < 0 {
// contentHeight = 0
// }
//
// newMsg := tea.WindowSizeMsg{Width: msg.Width, Height: contentHeight}
// activePage, cmd := m.activePage.Update(newMsg)
// m.activePage = activePage.(common.Component)
// return m, cmd
//
// case tea.KeyMsg:
// // Only handle tab key for page switching when at the top level (no subpages active)
// if key.Matches(msg, m.common.Keymap.Next) && !m.common.HasSubpages() {
// if m.activePage == m.taskPage {
// m.activePage = m.timePage
// m.currentTab = 1
// } else {
// m.activePage = m.taskPage
// m.currentTab = 0
// }
//
// tabHeight := lipgloss.Height(m.renderTabBar())
// contentHeight := m.height - tabHeight
// if contentHeight < 0 {
// contentHeight = 0
// }
// m.activePage.SetSize(m.width, contentHeight)
//
// // Trigger a refresh/init on switch? Maybe not needed if we keep state.
// // But we might want to refresh data.
// return m, m.activePage.Init()
// }
// }
//
activePage, cmd := m.activePage.Update(msg)
m.activePage = activePage.(common.Component)
return m, cmd
}
func (m *MainPage) renderTabBar() string {
var tabs []string
headers := []string{"Tasks", "Time"}
for i, header := range headers {
style := m.common.Styles.Tab
if m.currentTab == i {
style = m.common.Styles.ActiveTab
}
tabs = append(tabs, style.Render(header))
}
row := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
return m.common.Styles.TabBar.Width(m.common.Width()).Render(row)
}
func (m *MainPage) View() tea.View {
v := tea.NewView(lipgloss.JoinVertical(lipgloss.Left, m.renderTabBar(), m.activePage.View().Content))
v.AltScreen = true
return v
// return lipgloss.JoinVertical(lipgloss.Left, m.renderTabBar(), m.activePage.View())
}

355
internal/pages/tasks.go Normal file
View File

@@ -0,0 +1,355 @@
// TODO: update table every second (to show correct relative time)
package pages
import (
"tasksquire/internal/common"
"tasksquire/internal/components/tasktable"
"tasksquire/internal/taskwarrior"
tea "charm.land/bubbletea/v2"
// "charm.land/lipgloss/v2"
"charm.land/bubbles/v2/key"
)
type TaskPage struct {
common *common.Common
activeReport *taskwarrior.Report
activeContext *taskwarrior.Context
activeProject string
selectedTask *taskwarrior.Task
taskCursor int
tasks taskwarrior.Tasks
taskTable tasktable.Model
// Details panel state
// detailsPanelActive bool
// detailsViewer *detailsviewer.DetailsViewer
subpage common.Component
}
func NewTaskPage(com *common.Common, report *taskwarrior.Report) *TaskPage {
p := &TaskPage{
common: com,
activeReport: report,
activeContext: com.TW.GetActiveContext(),
activeProject: "",
taskTable: tasktable.New(),
// detailsPanelActive: false,
// detailsViewer: detailsviewer.New(com),
}
return p
}
func (p *TaskPage) SetSize(width int, height int) {
p.common.SetSize(width, height)
baseHeight := height - p.common.Styles.Base.GetVerticalFrameSize()
baseWidth := width - p.common.Styles.Base.GetHorizontalFrameSize()
var tableHeight int
// if p.detailsPanelActive {
// // Allocate 60% for table, 40% for details panel
// // Minimum 5 lines for details, minimum 10 lines for table
// detailsHeight := max(min(baseHeight*2/5, baseHeight-10), 5)
// tableHeight = baseHeight - detailsHeight - 1 // -1 for spacing
//
// // Set component size (component handles its own border/padding)
// // p.detailsViewer.SetSize(baseWidth, detailsHeight)
// } else {
tableHeight = baseHeight
// }
p.taskTable.SetWidth(baseWidth)
p.taskTable.SetHeight(tableHeight)
}
func (p *TaskPage) Init() tea.Cmd {
return tea.Batch(p.getTasks(), common.DoTick())
}
func (p *TaskPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
p.SetSize(msg.Width, msg.Height)
// case BackMsg:
case common.TickMsg:
cmds = append(cmds, p.getTasks())
cmds = append(cmds, common.DoTick())
return p, tea.Batch(cmds...)
case common.TaskMsg:
p.tasks = taskwarrior.Tasks(msg)
// case UpdateReportMsg:
// p.activeReport = msg
// cmds = append(cmds, p.getTasks())
// case UpdateContextMsg:
// p.activeContext = msg
// p.common.TW.SetContext(msg)
// p.populateTaskTable(p.tasks)
// cmds = append(cmds, p.getTasks())
// case UpdateProjectMsg:
// p.activeProject = string(msg)
// cmds = append(cmds, p.getTasks())
// case TaskPickedMsg:
// if msg.Task != nil && msg.Task.Status == "pending" {
// p.common.TW.StopActiveTasks()
// p.common.TW.StartTask(msg.Task)
// }
// cmds = append(cmds, p.getTasks())
// case UpdatedTasksMsg:
// cmds = append(cmds, p.getTasks())
case tea.KeyPressMsg:
// Handle ESC when details panel is active
// if p.detailsPanelActive && key.Matches(msg, p.common.Keymap.Back) {
// p.detailsPanelActive = false
// p.detailsViewer.Blur()
// p.SetSize(p.common.Width(), p.common.Height())
// return p, nil
// }
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)
// cmd := p.subpage.Init()
// p.common.PushPage(p)
// return p.subpage, cmd
// case key.Matches(msg, p.common.Keymap.SetContext):
// p.subpage = NewContextPickerPage(p.common)
// cmd := p.subpage.Init()
// p.common.PushPage(p)
// return p.subpage, cmd
// case key.Matches(msg, p.common.Keymap.Add):
// p.subpage = NewTaskEditorPage(p.common, taskwarrior.NewTask())
// cmd := p.subpage.Init()
// p.common.PushPage(p)
// return p.subpage, cmd
// case key.Matches(msg, p.common.Keymap.Edit):
// p.subpage = NewTaskEditorPage(p.common, *p.selectedTask)
// cmd := p.subpage.Init()
// p.common.PushPage(p)
// return p.subpage, cmd
// case key.Matches(msg, p.common.Keymap.Subtask):
// if p.selectedTask != nil {
// // Create new task inheriting parent's attributes
// newTask := taskwarrior.NewTask()
//
// // Set parent relationship
// newTask.Parent = p.selectedTask.Uuid
//
// // Copy parent's attributes
// newTask.Project = p.selectedTask.Project
// newTask.Priority = p.selectedTask.Priority
// newTask.Tags = make([]string, len(p.selectedTask.Tags))
// copy(newTask.Tags, p.selectedTask.Tags)
//
// // Copy UDAs (except "details" which is task-specific)
// if p.selectedTask.Udas != nil {
// newTask.Udas = make(map[string]any)
// for k, v := range p.selectedTask.Udas {
// // Skip "details" UDA - it's specific to parent task
// if k == "details" {
// continue
// }
// // Deep copy other UDA values
// newTask.Udas[k] = v
// }
// }
//
// // Open task editor with pre-populated task
// p.subpage = NewTaskEditorPage(p.common, newTask)
// cmd := p.subpage.Init()
// p.common.PushPage(p)
// return p.subpage, cmd
// }
// return p, nil
// case key.Matches(msg, p.common.Keymap.Ok):
// p.common.TW.SetTaskDone(p.selectedTask)
// return p, p.getTasks()
// case key.Matches(msg, p.common.Keymap.Delete):
// p.common.TW.DeleteTask(p.selectedTask)
// return p, p.getTasks()
// case key.Matches(msg, p.common.Keymap.SetProject):
// p.subpage = NewProjectPickerPage(p.common, p.activeProject)
// cmd := p.subpage.Init()
// p.common.PushPage(p)
// return p.subpage, cmd
// case key.Matches(msg, p.common.Keymap.PickProjectTask):
// p.subpage = NewProjectTaskPickerPage(p.common)
// cmd := p.subpage.Init()
// p.common.PushPage(p)
// return p.subpage, cmd
// case key.Matches(msg, p.common.Keymap.Tag):
// if p.selectedTask != nil {
// tag := p.common.TW.GetConfig().Get("uda.tasksquire.tag.default")
// if p.selectedTask.HasTag(tag) {
// p.selectedTask.RemoveTag(tag)
// } else {
// p.selectedTask.AddTag(tag)
// }
// p.common.TW.ImportTask(p.selectedTask)
// return p, p.getTasks()
// }
// return p, nil
// case key.Matches(msg, p.common.Keymap.Undo):
// p.common.TW.Undo()
// return p, p.getTasks()
// case key.Matches(msg, p.common.Keymap.StartStop):
// if p.selectedTask != nil && p.selectedTask.Status == "pending" {
// if p.selectedTask.Start == "" {
// p.common.TW.StopActiveTasks()
// p.common.TW.StartTask(p.selectedTask)
// } else {
// p.common.TW.StopTask(p.selectedTask)
// }
// return p, p.getTasks()
// }
// case key.Matches(msg, p.common.Keymap.ViewDetails):
// if p.selectedTask != nil {
// // Toggle details panel
// p.detailsPanelActive = !p.detailsPanelActive
// if p.detailsPanelActive {
// p.detailsViewer.SetTask(p.selectedTask)
// p.detailsViewer.Focus()
// } else {
// p.detailsViewer.Blur()
// }
// p.SetSize(p.common.Width(), p.common.Height())
// return p, nil
// }
// }
// }
//
// var cmd tea.Cmd
//
// // Route keyboard messages to details viewer when panel is active
// if p.detailsPanelActive {
// var viewerCmd tea.Cmd
// var viewerModel tea.Model
// viewerModel, viewerCmd = p.detailsViewer.Update(msg)
// p.detailsViewer = viewerModel.(*detailsviewer.DetailsViewer)
// cmds = append(cmds, viewerCmd)
// } else {
// // Route to table when details panel not active
// p.taskTable, cmd = p.taskTable.Update(msg)
// cmds = append(cmds, cmd)
//
// if p.tasks != nil && len(p.tasks) > 0 {
// p.selectedTask = p.tasks[p.taskTable.Cursor()]
// } else {
// p.selectedTask = nil
// }
// }
}
return p, tea.Batch(cmds...)
}
func (p *TaskPage) View() tea.View {
if len(p.tasks) == 0 {
return tea.NewView(p.common.Styles.Base.Render("No tasks found"))
}
tableView := p.taskTable.View()
return tea.NewView(tableView)
}
//
// if !p.detailsPanelActive {
// return tableView
// }
//
// // Combine table and details panel vertically
// return lipgloss.JoinVertical(
// lipgloss.Left,
// tableView,
// p.detailsViewer.View(),
// )
// }
//
// func (p *TaskPage) populateTaskTable(tasks taskwarrior.Tasks) {
// if len(tasks) == 0 {
// return
// }
//
// // Build task tree for hierarchical display
// taskTree := taskwarrior.BuildTaskTree(tasks)
//
// // Use flattened tree list for display order
// orderedTasks := make(taskwarrior.Tasks, len(taskTree.FlatList))
// for i, node := range taskTree.FlatList {
// orderedTasks[i] = node.Task
// }
//
// selected := p.taskTable.Cursor()
//
// // Adjust cursor for tree ordering
// if p.selectedTask != nil {
// for i, task := range orderedTasks {
// if task.Uuid == p.selectedTask.Uuid {
// selected = i
// break
// }
// }
// }
// if selected > len(orderedTasks)-1 {
// selected = len(orderedTasks) - 1
// }
//
// // Calculate proper dimensions based on whether details panel is active
// baseHeight := p.common.Height() - p.common.Styles.Base.GetVerticalFrameSize()
// baseWidth := p.common.Width() - p.common.Styles.Base.GetHorizontalFrameSize()
//
// var tableHeight int
// if p.detailsPanelActive {
// // Allocate 60% for table, 40% for details panel
// // Minimum 5 lines for details, minimum 10 lines for table
// detailsHeight := max(min(baseHeight*2/5, baseHeight-10), 5)
// tableHeight = baseHeight - detailsHeight - 1 // -1 for spacing
// } else {
// tableHeight = baseHeight
// }
//
// p.taskTable = table.New(
// p.common,
// able.WithReport(p.activeReport),
// table.WithTasks(orderedTasks),
// table.WithTaskTree(taskTree),
// table.WithFocused(true),
// table.WithWidth(baseWidth),
// table.WithHeight(tableHeight),
// table.WithStyles(p.common.Styles.TableStyle),
// )
//
// if selected == 0 {
// selected = p.taskTable.Cursor()
// }
// if selected < len(orderedTasks) {
// p.taskTable.SetCursor(selected)
// } else {
// p.taskTable.SetCursor(len(p.tasks) - 1)
// }
//
// // Refresh details content if panel is active
// if p.detailsPanelActive && p.selectedTask != nil {
// p.detailsViewer.SetTask(p.selectedTask)
// }
// }
//
func (p *TaskPage) getTasks() tea.Cmd {
return func() tea.Msg {
filters := []string{}
if p.activeProject != "" {
filters = append(filters, "project:"+p.activeProject)
}
tasks := p.common.TW.GetTasks(p.activeReport, filters...)
return common.TaskMsg(tasks)
}
}

View File

@@ -12,9 +12,10 @@ type TWConfig struct {
var (
defaultConfig = map[string]string{
"uda.tasksquire.report.default": "next",
"uda.tasksquire.tag.default": "next",
"uda.tasksquire.tags.default": "low_energy,customer,delegate,code,communication,research",
"uda.tasksquire.report.default": "next",
"uda.tasksquire.tag.default": "next",
"uda.tasksquire.tags.default": "mngmnt,ops,low_energy,cust,delegate,code,comm,research",
"uda.tasksquire.picker.filter_by_default": "yes",
}
)
@@ -38,7 +39,7 @@ func (tc *TWConfig) GetConfig() map[string]string {
func (tc *TWConfig) Get(key string) string {
if _, ok := tc.config[key]; !ok {
slog.Debug(fmt.Sprintf("Key not found in config: %s", key))
slog.Debug(fmt.Sprintf("Key not found in Taskwarrior config: %s", key))
return ""
}

View File

@@ -1,6 +1,7 @@
package taskwarrior
import (
"encoding/json"
"fmt"
"log/slog"
"math"
@@ -13,6 +14,23 @@ const (
dtformat = "20060102T150405Z"
)
type UdaType string
const (
UdaTypeString UdaType = "string"
UdaTypeDate UdaType = "date"
UdaTypeNumeric UdaType = "numeric"
UdaTypeDuration UdaType = "duration"
)
type Uda struct {
Name string
Type UdaType
Label string
Values []string
Default string
}
type Annotation struct {
Entry string `json:"entry,omitempty"`
Description string `json:"description,omitempty"`
@@ -22,29 +40,41 @@ func (a Annotation) String() string {
return fmt.Sprintf("%s %s", a.Entry, a.Description)
}
type Tasks []*Task
type Task struct {
Id int64 `json:"id,omitempty"`
Uuid string `json:"uuid,omitempty"`
Description string `json:"description,omitempty"`
Project string `json:"project"`
Priority string `json:"priority"`
Status string `json:"status,omitempty"`
Tags []string `json:"tags"`
VirtualTags []string `json:"-"`
Depends []string `json:"depends,omitempty"`
DependsIds string `json:"-"`
Urgency float32 `json:"urgency,omitempty"`
Parent string `json:"parent,omitempty"`
Due string `json:"due,omitempty"`
Wait string `json:"wait,omitempty"`
Scheduled string `json:"scheduled,omitempty"`
Until string `json:"until,omitempty"`
Start string `json:"start,omitempty"`
End string `json:"end,omitempty"`
Entry string `json:"entry,omitempty"`
Modified string `json:"modified,omitempty"`
Recur string `json:"recur,omitempty"`
Annotations []Annotation `json:"annotations,omitempty"`
Id int64 `json:"id,omitempty"`
Uuid string `json:"uuid,omitempty"`
Description string `json:"description,omitempty"`
Project string `json:"project"`
Priority string `json:"priority,omitempty"`
Status string `json:"status,omitempty"`
Tags []string `json:"tags"`
VirtualTags []string `json:"-"`
Depends []string `json:"depends,omitempty"`
DependsIds string `json:"-"`
Urgency float32 `json:"urgency,omitempty"`
Parent string `json:"parenttask,omitempty"`
Due string `json:"due,omitempty"`
Wait string `json:"wait,omitempty"`
Scheduled string `json:"scheduled,omitempty"`
Until string `json:"until,omitempty"`
Start string `json:"start,omitempty"`
End string `json:"end,omitempty"`
Entry string `json:"entry,omitempty"`
Modified string `json:"modified,omitempty"`
Recur string `json:"recur,omitempty"`
Annotations []Annotation `json:"annotations,omitempty"`
Udas map[string]any `json:"-"`
}
// TODO: fix pointer receiver
func NewTask() Task {
return Task{
Tags: make([]string, 0),
Depends: make([]string, 0),
Udas: make(map[string]any),
}
}
func (t *Task) GetString(fieldWFormat string) string {
@@ -90,19 +120,25 @@ func (t *Task) GetString(fieldWFormat string) string {
}
}
if len(t.Annotations) == 0 {
return t.Description
if t.Udas["details"] != nil && t.Udas["details"] != "" {
return fmt.Sprintf("%s [D]", t.Description)
} else {
// var annotations []string
// for _, a := range t.Annotations {
// annotations = append(annotations, a.String())
// }
// return fmt.Sprintf("%s\n%s", t.Description, strings.Join(annotations, "\n"))
// TODO enable support for multiline in table
return fmt.Sprintf("%s [%d]", t.Description, len(t.Annotations))
return t.Description
}
// if len(t.Annotations) == 0 {
// return t.Description
// } else {
// // var annotations []string
// // for _, a := range t.Annotations {
// // annotations = append(annotations, a.String())
// // }
// // return fmt.Sprintf("%s\n%s", t.Description, strings.Join(annotations, "\n"))
// // TODO enable support for multiline in table
// return fmt.Sprintf("%s [%d]", t.Description, len(t.Annotations))
// }
case "project":
switch format {
case "parent":
@@ -132,7 +168,7 @@ func (t *Task) GetString(fieldWFormat string) string {
}
return strings.Join(t.Tags, " ")
case "parent":
case "parenttask":
if format == "short" {
return t.Parent[:8]
}
@@ -185,15 +221,48 @@ func (t *Task) GetString(fieldWFormat string) string {
return t.Recur
default:
slog.Error(fmt.Sprintf("Field not implemented: %s", field))
return ""
// TODO: format according to UDA type
if val, ok := t.Udas[field]; ok {
if strVal, ok := val.(string); ok {
return strVal
}
}
}
slog.Error("Field not implemented", "field", field)
return ""
}
func (t *Task) GetDate(dateString string) time.Time {
func (t *Task) GetDate(field string) time.Time {
var dateString string
switch field {
case "due":
dateString = t.Due
case "wait":
dateString = t.Wait
case "scheduled":
dateString = t.Scheduled
case "until":
dateString = t.Until
case "start":
dateString = t.Start
case "end":
dateString = t.End
case "entry":
dateString = t.Entry
case "modified":
dateString = t.Modified
default:
return time.Time{}
}
if dateString == "" {
return time.Time{}
}
dt, err := time.Parse(dtformat, dateString)
if err != nil {
slog.Error("Failed to parse time:", err)
slog.Error("Failed to parse time", "error", err)
return time.Time{}
}
return dt
@@ -223,7 +292,69 @@ func (t *Task) RemoveTag(tag string) {
}
}
type Tasks []*Task
func (t *Task) UnmarshalJSON(data []byte) error {
type Alias Task
task := Alias{}
if err := json.Unmarshal(data, &task); err != nil {
return err
}
*t = Task(task)
m := make(map[string]any)
if err := json.Unmarshal(data, &m); err != nil {
return err
}
delete(m, "id")
delete(m, "uuid")
delete(m, "description")
delete(m, "project")
// delete(m, "priority")
delete(m, "status")
delete(m, "tags")
delete(m, "depends")
delete(m, "urgency")
delete(m, "parenttask")
delete(m, "due")
delete(m, "wait")
delete(m, "scheduled")
delete(m, "until")
delete(m, "start")
delete(m, "end")
delete(m, "entry")
delete(m, "modified")
delete(m, "recur")
delete(m, "annotations")
t.Udas = m
return nil
}
func (t *Task) MarshalJSON() ([]byte, error) {
type Alias Task
task := Alias(*t)
knownFields, err := json.Marshal(task)
if err != nil {
return nil, err
}
var knownMap map[string]any
if err := json.Unmarshal(knownFields, &knownMap); err != nil {
return nil, err
}
for key, value := range t.Udas {
if value != nil && value != "" {
knownMap[key] = value
}
}
return json.Marshal(knownMap)
}
type Context struct {
Name string
@@ -253,7 +384,7 @@ func formatDate(date string, format string) string {
dt, err := time.Parse(dtformat, date)
if err != nil {
slog.Error("Failed to parse time:", err)
slog.Error("Failed to parse time", "error", err)
return ""
}
@@ -277,7 +408,7 @@ func formatDate(date string, format string) string {
case "countdown":
return parseCountdown(time.Since(dt))
default:
slog.Error(fmt.Sprintf("Date format not implemented: %s", format))
slog.Error("Date format not implemented", "format", format)
return ""
}
}
@@ -411,3 +542,15 @@ func ValidateDate(s string) error {
return fmt.Errorf("invalid date")
}
func ValidateNumeric(s string) error {
if _, err := strconv.ParseFloat(s, 64); err != nil {
return fmt.Errorf("invalid number")
}
return nil
}
func ValidateDuration(s string) error {
// TODO: implement duration validation
return nil
}

View File

@@ -0,0 +1,81 @@
package taskwarrior
import (
"testing"
"time"
)
func TestTask_GetString(t *testing.T) {
tests := []struct {
name string
task Task
fieldWFormat string
want string
}{
{
name: "Priority",
task: Task{
Priority: "H",
},
fieldWFormat: "priority",
want: "H",
},
{
name: "Description",
task: Task{
Description: "Buy milk",
},
fieldWFormat: "description.desc",
want: "Buy milk",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.task.GetString(tt.fieldWFormat); got != tt.want {
t.Errorf("Task.GetString() = %v, want %v", got, tt.want)
}
})
}
}
func TestTask_GetDate(t *testing.T) {
validDate := "20230101T120000Z"
parsedValid, _ := time.Parse("20060102T150405Z", validDate)
tests := []struct {
name string
task Task
field string
want time.Time
}{
{
name: "Due date valid",
task: Task{
Due: validDate,
},
field: "due",
want: parsedValid,
},
{
name: "Due date empty",
task: Task{},
field: "due",
want: time.Time{},
},
{
name: "Unknown field",
task: Task{Due: validDate},
field: "unknown",
want: time.Time{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.task.GetDate(tt.field); !got.Equal(tt.want) {
t.Errorf("Task.GetDate() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,670 @@
// TODO: error handling
// TODO: split combinedOutput and handle stderr differently
// TODO: reorder functions
package taskwarrior
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"os/exec"
"regexp"
"slices"
"strings"
"sync"
)
const (
twBinary = "task"
)
var (
nonStandardReports = map[string]struct{}{
"burndown.daily": {},
"burndown.monthly": {},
"burndown.weekly": {},
"calendar": {},
"colors": {},
"export": {},
"ghistory.annual": {},
"ghistory.monthly": {},
"history.annual": {},
"history.monthly": {},
"information": {},
"summary": {},
"timesheet": {},
}
virtualTags = 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
GetUdas() []Uda
GetTasks(report *Report, filter ...string) Tasks
// AddTask(task *Task) error
ImportTask(task *Task)
SetTaskDone(task *Task)
DeleteTask(task *Task)
StartTask(task *Task)
StopTask(task *Task)
StopActiveTasks()
GetInformation(task *Task) string
AddTaskAnnotation(uuid string, annotation string)
Undo()
}
type TaskwarriorInterop struct {
configLocation string
defaultArgs []string
config *TWConfig
reports Reports
contexts Contexts
ctx context.Context
mutex sync.Mutex
}
func NewTaskwarriorInterop(ctx context.Context, configLocation string) *TaskwarriorInterop {
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 := &TaskwarriorInterop{
configLocation: configLocation,
defaultArgs: defaultArgs,
ctx: ctx,
mutex: sync.Mutex{},
}
ts.config = ts.extractConfig()
if ts.config == nil {
slog.Error("Failed to extract config - taskwarrior commands are failing. Check your taskrc file for syntax errors.")
return nil
}
ts.reports = ts.extractReports()
ts.contexts = ts.extractContexts()
return ts
}
func (ts *TaskwarriorInterop) GetConfig() *TWConfig {
ts.mutex.Lock()
defer ts.mutex.Unlock()
return ts.config
}
func (ts *TaskwarriorInterop) GetTasks(report *Report, filter ...string) Tasks {
ts.mutex.Lock()
defer ts.mutex.Unlock()
args := ts.defaultArgs
if report != nil && report.Context {
for _, context := range ts.contexts {
if context.Active && context.Name != "none" {
args = append(args, context.ReadFilter)
break
}
}
}
if filter != nil {
args = append(args, filter...)
}
exportArgs := []string{"export"}
if report != nil && report.Name != "" {
exportArgs = append(exportArgs, report.Name)
}
cmd := exec.CommandContext(ts.ctx, twBinary, append(args, exportArgs...)...)
output, err := cmd.CombinedOutput()
if err != nil {
if ts.ctx.Err() == context.Canceled {
return nil
}
slog.Error("Failed getting report", "error", err)
return nil
}
tasks := make(Tasks, 0)
err = json.Unmarshal(output, &tasks)
if err != nil {
slog.Error("Failed unmarshalling tasks", "error", err)
return nil
}
for _, task := range tasks {
if len(task.Depends) > 0 {
ids := make([]string, len(task.Depends))
for i, dependUuid := range task.Depends {
ids[i] = ts.getIds([]string{fmt.Sprintf("uuid:%s", dependUuid)})
}
task.DependsIds = strings.Join(ids, " ")
}
}
return tasks
}
func (ts *TaskwarriorInterop) getIds(filter []string) string {
cmd := exec.CommandContext(ts.ctx, twBinary, append(append(ts.defaultArgs, filter...), "_ids")...)
out, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed getting field", "error", err)
return ""
}
return strings.TrimSpace(string(out))
}
func (ts *TaskwarriorInterop) GetContext(context string) *Context {
ts.mutex.Lock()
defer ts.mutex.Unlock()
if context == "" {
context = "none"
}
if context, ok := ts.contexts[context]; ok {
return context
} else {
slog.Error("Context not found", "name", context)
return nil
}
}
func (ts *TaskwarriorInterop) 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 *TaskwarriorInterop) GetContexts() Contexts {
ts.mutex.Lock()
defer ts.mutex.Unlock()
return ts.contexts
}
func (ts *TaskwarriorInterop) GetProjects() []string {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_projects"}...)...)
output, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed getting projects", "error", 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 *TaskwarriorInterop) 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 *TaskwarriorInterop) GetTags() []string {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_tags"}...)...)
output, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed getting tags", "error", err)
return nil
}
tags := make([]string, 0)
tagSet := make(map[string]struct{})
for _, tag := range strings.Split(string(output), "\n") {
if _, ok := virtualTags[tag]; !ok && tag != "" {
tags = append(tags, tag)
tagSet[tag] = struct{}{}
}
}
for _, tag := range strings.Split(ts.config.Get("uda.TaskwarriorInterop.tags.default"), ",") {
if _, ok := tagSet[tag]; !ok {
tags = append(tags, tag)
}
}
slices.Sort(tags)
return tags
}
func (ts *TaskwarriorInterop) GetReport(report string) *Report {
ts.mutex.Lock()
defer ts.mutex.Unlock()
return ts.reports[report]
}
func (ts *TaskwarriorInterop) GetReports() Reports {
ts.mutex.Lock()
defer ts.mutex.Unlock()
return ts.reports
}
func (ts *TaskwarriorInterop) GetUdas() []Uda {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "_udas")...)
output, err := cmd.CombinedOutput()
if err != nil {
if ts.ctx.Err() == context.Canceled {
return nil
}
slog.Error("Failed getting UDAs", "error", err)
return nil
}
udas := make([]Uda, 0)
for _, uda := range strings.Split(string(output), "\n") {
if uda != "" {
udatype := UdaType(ts.config.Get(fmt.Sprintf("uda.%s.type", uda)))
if udatype == "" {
slog.Error("UDA type not found", "uda", uda)
continue
}
label := ts.config.Get(fmt.Sprintf("uda.%s.label", uda))
values := strings.Split(ts.config.Get(fmt.Sprintf("uda.%s.values", uda)), ",")
def := ts.config.Get(fmt.Sprintf("uda.%s.default", uda))
uda := Uda{
Name: uda,
Label: label,
Type: udatype,
Values: values,
Default: def,
}
udas = append(udas, uda)
}
}
return udas
}
func (ts *TaskwarriorInterop) SetContext(context *Context) error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
if context.Name == "none" && ts.contexts["none"].Active {
return nil
}
cmd := exec.CommandContext(ts.ctx, twBinary, []string{"context", context.Name}...)
if err := cmd.Run(); err != nil {
slog.Error("Failed setting context", "error", 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 *TaskwarriorInterop) 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
// }
// TODO error handling
func (ts *TaskwarriorInterop) ImportTask(task *Task) {
ts.mutex.Lock()
defer ts.mutex.Unlock()
tasks, err := json.Marshal(Tasks{task})
if err != nil {
slog.Error("Failed marshalling task", "error", err)
}
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"import", "-"}...)...)
cmd.Stdin = bytes.NewBuffer(tasks)
out, err := cmd.CombinedOutput()
if err != nil {
if ts.ctx.Err() == context.Canceled {
return
}
slog.Error("Failed modifying task", "error", err, "output", string(out))
}
}
func (ts *TaskwarriorInterop) SetTaskDone(task *Task) {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"done", task.Uuid}...)...)
err := cmd.Run()
if err != nil {
slog.Error("Failed setting task done", "error", err)
}
}
func (ts *TaskwarriorInterop) DeleteTask(task *Task) {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{task.Uuid, "delete"}...)...)
err := cmd.Run()
if err != nil {
slog.Error("Failed deleting task", "error", err)
}
}
func (ts *TaskwarriorInterop) Undo() {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"undo"}...)...)
err := cmd.Run()
if err != nil {
slog.Error("Failed undoing task", "error", err)
}
}
func (ts *TaskwarriorInterop) StartTask(task *Task) {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"start", task.Uuid}...)...)
err := cmd.Run()
if err != nil {
slog.Error("Failed starting task", "error", err)
}
}
func (ts *TaskwarriorInterop) StopTask(task *Task) {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"stop", task.Uuid}...)...)
err := cmd.Run()
if err != nil {
slog.Error("Failed stopping task", "error", err)
}
}
func (ts *TaskwarriorInterop) StopActiveTasks() {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"+ACTIVE", "export"}...)...)
output, err := cmd.CombinedOutput()
if err != nil {
if ts.ctx.Err() == context.Canceled {
return
}
slog.Error("Failed getting active tasks", "error", err, "output", string(output))
return
}
tasks := make(Tasks, 0)
err = json.Unmarshal(output, &tasks)
if err != nil {
slog.Error("Failed unmarshalling active tasks", "error", err)
return
}
for _, task := range tasks {
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"stop", task.Uuid}...)...)
err := cmd.Run()
if err != nil {
slog.Error("Failed stopping task", "error", err)
}
}
}
func (ts *TaskwarriorInterop) GetInformation(task *Task) string {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{fmt.Sprintf("uuid:%s", task.Uuid), "information"}...)...)
output, err := cmd.CombinedOutput()
if err != nil {
if ts.ctx.Err() == context.Canceled {
return ""
}
slog.Error("Failed getting task information", "error", err)
return ""
}
return string(output)
}
func (ts *TaskwarriorInterop) AddTaskAnnotation(uuid string, annotation string) {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{uuid, "annotate", annotation}...)...)
err := cmd.Run()
if err != nil {
slog.Error("Failed adding annotation", "error", err)
}
}
func (ts *TaskwarriorInterop) extractConfig() *TWConfig {
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_show"}...)...)
output, err := cmd.CombinedOutput()
if err != nil {
if ts.ctx.Err() == context.Canceled {
return nil
}
slog.Error("Failed getting config", "error", err, "output", string(output))
return nil
}
return NewConfig(strings.Split(string(output), "\n"))
}
func (ts *TaskwarriorInterop) extractReports() Reports {
cmd := exec.CommandContext(ts.ctx, 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 {
if _, ok := nonStandardReports[report]; ok {
continue
}
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 *TaskwarriorInterop) extractContexts() Contexts {
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_context"}...)...)
output, err := cmd.CombinedOutput()
if err != nil {
if ts.ctx.Err() == context.Canceled {
return nil
}
slog.Error("Failed getting contexts", "error", err, "output", string(output))
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

@@ -1,6 +1,7 @@
package taskwarrior
import (
"context"
"fmt"
"os"
"testing"
@@ -56,7 +57,7 @@ func TestTaskSquire_GetContext(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.prep()
ts := NewTaskSquire(tt.fields.configLocation)
ts := NewTaskSquire(context.Background(), tt.fields.configLocation)
if got := ts.GetActiveContext(); got.Name != tt.want {
t.Errorf("TaskSquire.GetContext() = %v, want %v", got, tt.want)
}

View File

@@ -0,0 +1,127 @@
package taskwarrior
import (
"log/slog"
)
// TaskNode represents a task in the tree structure
type TaskNode struct {
Task *Task
Children []*TaskNode
Parent *TaskNode
Depth int
}
// TaskTree manages the hierarchical task structure
type TaskTree struct {
Nodes map[string]*TaskNode // UUID -> TaskNode
Roots []*TaskNode // Top-level tasks (no parent)
FlatList []*TaskNode // Flattened tree in display order
}
// BuildTaskTree constructs a tree from a flat list of tasks
// Three-pass algorithm:
// 1. Create all nodes
// 2. Establish parent-child relationships
// 3. Calculate depths and flatten tree
func BuildTaskTree(tasks Tasks) *TaskTree {
tree := &TaskTree{
Nodes: make(map[string]*TaskNode),
Roots: make([]*TaskNode, 0),
FlatList: make([]*TaskNode, 0),
}
// Pass 1: Create all nodes
for _, task := range tasks {
node := &TaskNode{
Task: task,
Children: make([]*TaskNode, 0),
Parent: nil,
Depth: 0,
}
tree.Nodes[task.Uuid] = node
}
// Pass 2: Establish parent-child relationships
// Iterate over original tasks slice to preserve order
for _, task := range tasks {
node := tree.Nodes[task.Uuid]
parentUUID := getParentUUID(node.Task)
if parentUUID == "" {
// No parent, this is a root task
tree.Roots = append(tree.Roots, node)
} else {
// Find parent node
parentNode, exists := tree.Nodes[parentUUID]
if !exists {
// Orphaned task - missing parent
slog.Warn("Task has missing parent",
"task_uuid", node.Task.Uuid,
"parent_uuid", parentUUID,
"task_desc", node.Task.Description)
// Treat as root (graceful degradation)
tree.Roots = append(tree.Roots, node)
} else {
// Establish relationship
node.Parent = parentNode
parentNode.Children = append(parentNode.Children, node)
}
}
}
// Pass 3: Calculate depths and flatten tree
for _, root := range tree.Roots {
flattenNode(root, 0, &tree.FlatList)
}
return tree
}
// getParentUUID extracts the parent UUID from a task's UDAs
func getParentUUID(task *Task) string {
if task.Udas == nil {
return ""
}
parentVal, exists := task.Udas["parenttask"]
if !exists {
return ""
}
// Parent UDA is stored as a string
if parentStr, ok := parentVal.(string); ok {
return parentStr
}
return ""
}
// flattenNode recursively flattens the tree in depth-first order
func flattenNode(node *TaskNode, depth int, flatList *[]*TaskNode) {
node.Depth = depth
*flatList = append(*flatList, node)
// Recursively flatten children
for _, child := range node.Children {
flattenNode(child, depth+1, flatList)
}
}
// GetChildrenStatus returns completed/total counts for a parent task
func (tn *TaskNode) GetChildrenStatus() (completed int, total int) {
total = len(tn.Children)
completed = 0
for _, child := range tn.Children {
if child.Task.Status == "completed" {
completed++
}
}
return completed, total
}
// HasChildren returns true if the node has any children
func (tn *TaskNode) HasChildren() bool {
return len(tn.Children) > 0
}

View File

@@ -0,0 +1,345 @@
package taskwarrior
import (
"testing"
)
func TestBuildTaskTree_EmptyList(t *testing.T) {
tasks := Tasks{}
tree := BuildTaskTree(tasks)
if tree == nil {
t.Fatal("Expected tree to be non-nil")
}
if len(tree.Nodes) != 0 {
t.Errorf("Expected 0 nodes, got %d", len(tree.Nodes))
}
if len(tree.Roots) != 0 {
t.Errorf("Expected 0 roots, got %d", len(tree.Roots))
}
if len(tree.FlatList) != 0 {
t.Errorf("Expected 0 items in flat list, got %d", len(tree.FlatList))
}
}
func TestBuildTaskTree_NoParents(t *testing.T) {
tasks := Tasks{
{Uuid: "task1", Description: "Task 1", Status: "pending"},
{Uuid: "task2", Description: "Task 2", Status: "pending"},
{Uuid: "task3", Description: "Task 3", Status: "completed"},
}
tree := BuildTaskTree(tasks)
if len(tree.Nodes) != 3 {
t.Errorf("Expected 3 nodes, got %d", len(tree.Nodes))
}
if len(tree.Roots) != 3 {
t.Errorf("Expected 3 roots (all tasks have no parent), got %d", len(tree.Roots))
}
if len(tree.FlatList) != 3 {
t.Errorf("Expected 3 items in flat list, got %d", len(tree.FlatList))
}
// All tasks should have depth 0
for i, node := range tree.FlatList {
if node.Depth != 0 {
t.Errorf("Task %d expected depth 0, got %d", i, node.Depth)
}
if node.HasChildren() {
t.Errorf("Task %d should not have children", i)
}
}
}
func TestBuildTaskTree_SimpleParentChild(t *testing.T) {
tasks := Tasks{
{Uuid: "parent1", Description: "Parent Task", Status: "pending"},
{
Uuid: "child1",
Description: "Child Task 1",
Status: "pending",
Udas: map[string]any{"parenttask": "parent1"},
},
{
Uuid: "child2",
Description: "Child Task 2",
Status: "completed",
Udas: map[string]any{"parenttask": "parent1"},
},
}
tree := BuildTaskTree(tasks)
if len(tree.Nodes) != 3 {
t.Fatalf("Expected 3 nodes, got %d", len(tree.Nodes))
}
if len(tree.Roots) != 1 {
t.Fatalf("Expected 1 root, got %d", len(tree.Roots))
}
// Check root is the parent
root := tree.Roots[0]
if root.Task.Uuid != "parent1" {
t.Errorf("Expected root to be parent1, got %s", root.Task.Uuid)
}
// Check parent has 2 children
if len(root.Children) != 2 {
t.Fatalf("Expected parent to have 2 children, got %d", len(root.Children))
}
// Check children status
completed, total := root.GetChildrenStatus()
if total != 2 {
t.Errorf("Expected total children = 2, got %d", total)
}
if completed != 1 {
t.Errorf("Expected completed children = 1, got %d", completed)
}
// Check flat list order (parent first, then children)
if len(tree.FlatList) != 3 {
t.Fatalf("Expected 3 items in flat list, got %d", len(tree.FlatList))
}
if tree.FlatList[0].Task.Uuid != "parent1" {
t.Errorf("Expected first item to be parent, got %s", tree.FlatList[0].Task.Uuid)
}
if tree.FlatList[0].Depth != 0 {
t.Errorf("Expected parent depth = 0, got %d", tree.FlatList[0].Depth)
}
// Children should be at depth 1
for i := 1; i < 3; i++ {
if tree.FlatList[i].Depth != 1 {
t.Errorf("Expected child %d depth = 1, got %d", i, tree.FlatList[i].Depth)
}
if tree.FlatList[i].Parent == nil {
t.Errorf("Child %d should have a parent", i)
} else if tree.FlatList[i].Parent.Task.Uuid != "parent1" {
t.Errorf("Child %d parent should be parent1, got %s", i, tree.FlatList[i].Parent.Task.Uuid)
}
}
}
func TestBuildTaskTree_MultiLevel(t *testing.T) {
tasks := Tasks{
{Uuid: "grandparent", Description: "Grandparent", Status: "pending"},
{
Uuid: "parent1",
Description: "Parent 1",
Status: "pending",
Udas: map[string]any{"parenttask": "grandparent"},
},
{
Uuid: "parent2",
Description: "Parent 2",
Status: "pending",
Udas: map[string]any{"parenttask": "grandparent"},
},
{
Uuid: "child1",
Description: "Child 1",
Status: "pending",
Udas: map[string]any{"parenttask": "parent1"},
},
{
Uuid: "grandchild1",
Description: "Grandchild 1",
Status: "completed",
Udas: map[string]any{"parenttask": "child1"},
},
}
tree := BuildTaskTree(tasks)
if len(tree.Nodes) != 5 {
t.Fatalf("Expected 5 nodes, got %d", len(tree.Nodes))
}
if len(tree.Roots) != 1 {
t.Fatalf("Expected 1 root, got %d", len(tree.Roots))
}
// Find nodes by UUID
grandparentNode := tree.Nodes["grandparent"]
parent1Node := tree.Nodes["parent1"]
child1Node := tree.Nodes["child1"]
grandchildNode := tree.Nodes["grandchild1"]
// Check depths
if grandparentNode.Depth != 0 {
t.Errorf("Expected grandparent depth = 0, got %d", grandparentNode.Depth)
}
if parent1Node.Depth != 1 {
t.Errorf("Expected parent1 depth = 1, got %d", parent1Node.Depth)
}
if child1Node.Depth != 2 {
t.Errorf("Expected child1 depth = 2, got %d", child1Node.Depth)
}
if grandchildNode.Depth != 3 {
t.Errorf("Expected grandchild depth = 3, got %d", grandchildNode.Depth)
}
// Check parent-child relationships
if len(grandparentNode.Children) != 2 {
t.Errorf("Expected grandparent to have 2 children, got %d", len(grandparentNode.Children))
}
if len(parent1Node.Children) != 1 {
t.Errorf("Expected parent1 to have 1 child, got %d", len(parent1Node.Children))
}
if len(child1Node.Children) != 1 {
t.Errorf("Expected child1 to have 1 child, got %d", len(child1Node.Children))
}
if grandchildNode.HasChildren() {
t.Error("Expected grandchild to have no children")
}
// Check flat list maintains tree order
if len(tree.FlatList) != 5 {
t.Fatalf("Expected 5 items in flat list, got %d", len(tree.FlatList))
}
// Grandparent should be first
if tree.FlatList[0].Task.Uuid != "grandparent" {
t.Errorf("Expected first item to be grandparent, got %s", tree.FlatList[0].Task.Uuid)
}
}
func TestBuildTaskTree_OrphanedTask(t *testing.T) {
tasks := Tasks{
{Uuid: "task1", Description: "Normal Task", Status: "pending"},
{
Uuid: "orphan",
Description: "Orphaned Task",
Status: "pending",
Udas: map[string]any{"parenttask": "nonexistent"},
},
}
tree := BuildTaskTree(tasks)
if len(tree.Nodes) != 2 {
t.Fatalf("Expected 2 nodes, got %d", len(tree.Nodes))
}
// Orphaned task should be treated as root
if len(tree.Roots) != 2 {
t.Errorf("Expected 2 roots (orphan should be treated as root), got %d", len(tree.Roots))
}
// Both should have depth 0
for _, node := range tree.FlatList {
if node.Depth != 0 {
t.Errorf("Expected all tasks to have depth 0, got %d for %s", node.Depth, node.Task.Description)
}
}
}
func TestTaskNode_GetChildrenStatus(t *testing.T) {
tests := []struct {
name string
children []*TaskNode
wantComp int
wantTotal int
}{
{
name: "no children",
children: []*TaskNode{},
wantComp: 0,
wantTotal: 0,
},
{
name: "all pending",
children: []*TaskNode{
{Task: &Task{Status: "pending"}},
{Task: &Task{Status: "pending"}},
},
wantComp: 0,
wantTotal: 2,
},
{
name: "all completed",
children: []*TaskNode{
{Task: &Task{Status: "completed"}},
{Task: &Task{Status: "completed"}},
{Task: &Task{Status: "completed"}},
},
wantComp: 3,
wantTotal: 3,
},
{
name: "mixed status",
children: []*TaskNode{
{Task: &Task{Status: "completed"}},
{Task: &Task{Status: "pending"}},
{Task: &Task{Status: "completed"}},
{Task: &Task{Status: "pending"}},
{Task: &Task{Status: "completed"}},
},
wantComp: 3,
wantTotal: 5,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
node := &TaskNode{
Task: &Task{},
Children: tt.children,
}
gotComp, gotTotal := node.GetChildrenStatus()
if gotComp != tt.wantComp {
t.Errorf("GetChildrenStatus() completed = %d, want %d", gotComp, tt.wantComp)
}
if gotTotal != tt.wantTotal {
t.Errorf("GetChildrenStatus() total = %d, want %d", gotTotal, tt.wantTotal)
}
})
}
}
func TestTaskNode_HasChildren(t *testing.T) {
tests := []struct {
name string
children []*TaskNode
want bool
}{
{
name: "no children",
children: []*TaskNode{},
want: false,
},
{
name: "has children",
children: []*TaskNode{{Task: &Task{}}},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
node := &TaskNode{
Task: &Task{},
Children: tt.children,
}
if got := node.HasChildren(); got != tt.want {
t.Errorf("HasChildren() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,78 @@
package timewarrior
import (
"fmt"
"log/slog"
"strings"
)
type TWConfig struct {
config map[string]string
}
var (
defaultConfig = map[string]string{
"uda.timesquire.default.tag": "",
}
)
func NewConfig(config []string) *TWConfig {
cfg := parseConfig(config)
for key, value := range defaultConfig {
if _, ok := cfg[key]; !ok {
cfg[key] = value
}
}
return &TWConfig{
config: cfg,
}
}
func (tc *TWConfig) GetConfig() map[string]string {
return tc.config
}
func (tc *TWConfig) Get(key string) string {
if _, ok := tc.config[key]; !ok {
slog.Debug(fmt.Sprintf("Key not found in config: %s", key))
return ""
}
return tc.config[key]
}
func parseConfig(config []string) map[string]string {
configMap := make(map[string]string)
for _, line := range config {
// Skip empty lines and comments
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// Timewarrior config format: key = value or key: value
var key, value string
if strings.Contains(line, "=") {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
key = strings.TrimSpace(parts[0])
value = strings.TrimSpace(parts[1])
}
} else if strings.Contains(line, ":") {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
key = strings.TrimSpace(parts[0])
value = strings.TrimSpace(parts[1])
}
}
if key != "" {
configMap[key] = value
}
}
return configMap
}

View File

@@ -0,0 +1,323 @@
package timewarrior
import (
"fmt"
"log/slog"
"math"
"strconv"
"strings"
"time"
)
const (
dtformat = "20060102T150405Z"
)
type Intervals []*Interval
type Interval struct {
ID int `json:"-"`
Start string `json:"start,omitempty"`
End string `json:"end,omitempty"`
Tags []string `json:"tags,omitempty"`
IsGap bool `json:"-"` // True if this represents an untracked time gap
}
func NewInterval() *Interval {
return &Interval{
Tags: make([]string, 0),
}
}
// NewGapInterval creates a new gap interval representing untracked time.
// start and end are the times between which the gap occurred.
func NewGapInterval(start, end time.Time) *Interval {
return &Interval{
ID: -1, // Gap intervals have no real ID
Start: start.UTC().Format(dtformat),
End: end.UTC().Format(dtformat),
Tags: make([]string, 0),
IsGap: true,
}
}
func (i *Interval) GetString(field string) string {
// Special handling for gap intervals
if i.IsGap {
switch field {
case "duration":
return i.GetDuration()
case "gap_display":
return fmt.Sprintf("--- Untracked: %s ---", i.GetDuration())
default:
return ""
}
}
switch field {
case "id":
return strconv.Itoa(i.ID)
case "start":
return formatDate(i.Start, "formatted")
case "start_time":
return formatDate(i.Start, "time")
case "end":
if i.End == "" {
return "now"
}
return formatDate(i.End, "formatted")
case "end_time":
if i.End == "" {
return "now"
}
return formatDate(i.End, "time")
case "weekday":
return formatDate(i.Start, "weekday")
case "tags":
if len(i.Tags) == 0 {
return ""
}
// Extract and filter special tags (uuid:, project:)
_, _, displayTags := ExtractSpecialTags(i.Tags)
return strings.Join(displayTags, " ")
case "project":
project := ExtractProject(i.Tags)
if project == "" {
return "(none)"
}
return project
case "duration":
return i.GetDuration()
case "active":
if i.End == "" {
return "●"
}
return ""
default:
slog.Error("Field not implemented", "field", field)
return ""
}
}
func (i *Interval) GetDuration() string {
start, err := time.Parse(dtformat, i.Start)
if err != nil {
slog.Error("Failed to parse start time", "error", err)
return ""
}
var end time.Time
if i.End == "" {
end = time.Now()
} else {
end, err = time.Parse(dtformat, i.End)
if err != nil {
slog.Error("Failed to parse end time", "error", err)
return ""
}
}
duration := end.Sub(start)
return formatDuration(duration)
}
func (i *Interval) GetStartTime() time.Time {
dt, err := time.Parse(dtformat, i.Start)
if err != nil {
slog.Error("Failed to parse time", "error", err)
return time.Time{}
}
return dt
}
func (i *Interval) GetEndTime() time.Time {
if i.End == "" {
return time.Now()
}
dt, err := time.Parse(dtformat, i.End)
if err != nil {
slog.Error("Failed to parse time", "error", err)
return time.Time{}
}
return dt
}
func (i *Interval) HasTag(tag string) bool {
for _, t := range i.Tags {
if t == tag {
return true
}
}
return false
}
func (i *Interval) AddTag(tag string) {
if !i.HasTag(tag) {
i.Tags = append(i.Tags, tag)
}
}
func (i *Interval) RemoveTag(tag string) {
for idx, t := range i.Tags {
if t == tag {
i.Tags = append(i.Tags[:idx], i.Tags[idx+1:]...)
return
}
}
}
func (i *Interval) IsActive() bool {
return i.End == ""
}
func formatDate(date string, format string) string {
if date == "" {
return ""
}
dt, err := time.Parse(dtformat, date)
if err != nil {
slog.Error("Failed to parse time", "error", err)
return ""
}
dt = dt.Local()
switch format {
case "formatted", "":
return dt.Format("2006-01-02 15:04")
case "time":
return dt.Format("15:04")
case "date":
return dt.Format("2006-01-02")
case "weekday":
return dt.Format("Mon")
case "iso":
return dt.Format("2006-01-02T150405Z")
case "epoch":
return strconv.FormatInt(dt.Unix(), 10)
case "age":
return parseDurationVague(time.Since(dt))
case "relative":
return parseDurationVague(time.Until(dt))
default:
slog.Error("Date format not implemented", "format", format)
return ""
}
}
func formatDuration(d time.Duration) string {
hours := int(d.Hours())
minutes := int(d.Minutes()) % 60
seconds := int(d.Seconds()) % 60
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds)
}
func parseDurationVague(d time.Duration) string {
dur := d.Round(time.Second).Abs()
days := dur.Hours() / 24
var formatted string
if dur >= time.Hour*24*365 {
formatted = fmt.Sprintf("%.1fy", days/365)
} else if dur >= time.Hour*24*90 {
formatted = strconv.Itoa(int(math.Round(days/30))) + "mo"
} else if dur >= time.Hour*24*7 {
formatted = strconv.Itoa(int(math.Round(days/7))) + "w"
} else if dur >= time.Hour*24 {
formatted = strconv.Itoa(int(days)) + "d"
} else if dur >= time.Hour {
formatted = strconv.Itoa(int(dur.Round(time.Hour).Hours())) + "h"
} else if dur >= time.Minute {
formatted = strconv.Itoa(int(dur.Round(time.Minute).Minutes())) + "min"
} else if dur >= time.Second {
formatted = strconv.Itoa(int(dur.Round(time.Second).Seconds())) + "s"
}
if d < 0 {
formatted = "-" + formatted
}
return formatted
}
var (
dateFormats = []string{
"2006-01-02",
"2006-01-02T15:04",
"20060102T150405Z",
}
specialDateFormats = []string{
"",
"now",
"today",
"yesterday",
"tomorrow",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
"mon",
"tue",
"wed",
"thu",
"fri",
"sat",
"sun",
}
)
func ValidateDate(s string) error {
for _, f := range dateFormats {
if _, err := time.Parse(f, s); err == nil {
return nil
}
}
for _, f := range specialDateFormats {
if s == f {
return nil
}
}
return fmt.Errorf("invalid date")
}
func ValidateDuration(s string) error {
// TODO: implement proper duration validation
// Should accept formats like: 1h, 30m, 1h30m, etc.
return nil
}
// Summary represents time tracking summary data
type Summary struct {
Range string
TotalTime time.Duration
ByTag map[string]time.Duration
}
func (s *Summary) GetTotalString() string {
return formatDuration(s.TotalTime)
}
func (s *Summary) GetTagTime(tag string) string {
if duration, ok := s.ByTag[tag]; ok {
return formatDuration(duration)
}
return "0:00"
}

View File

@@ -0,0 +1,54 @@
package timewarrior
import (
"strings"
)
// Special tag prefixes for metadata
const (
UUIDPrefix = "uuid:"
ProjectPrefix = "project:"
)
// ExtractSpecialTags parses tags and separates special prefixed tags from display tags.
// Returns: uuid, project, and remaining display tags (description + user tags)
func ExtractSpecialTags(tags []string) (uuid string, project string, displayTags []string) {
displayTags = make([]string, 0, len(tags))
for _, tag := range tags {
switch {
case strings.HasPrefix(tag, UUIDPrefix):
uuid = strings.TrimPrefix(tag, UUIDPrefix)
case strings.HasPrefix(tag, ProjectPrefix):
project = strings.TrimPrefix(tag, ProjectPrefix)
case tag == "track":
// Skip the "track" tag - it's internal metadata
continue
default:
// Regular tag (description or user tag)
displayTags = append(displayTags, tag)
}
}
return uuid, project, displayTags
}
// ExtractUUID extracts just the UUID from tags (for sync operations)
func ExtractUUID(tags []string) string {
for _, tag := range tags {
if strings.HasPrefix(tag, UUIDPrefix) {
return strings.TrimPrefix(tag, UUIDPrefix)
}
}
return ""
}
// ExtractProject extracts just the project name from tags
func ExtractProject(tags []string) string {
for _, tag := range tags {
if strings.HasPrefix(tag, ProjectPrefix) {
return strings.TrimPrefix(tag, ProjectPrefix)
}
}
return ""
}

View File

@@ -0,0 +1,411 @@
// TODO: error handling
// TODO: split combinedOutput and handle stderr differently
package timewarrior
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"os/exec"
"regexp"
"slices"
"strings"
"sync"
)
const (
twBinary = "timew"
)
type TimeWarrior interface {
GetConfig() *TWConfig
GetTags() []string
GetTagCombinations() []string
GetIntervals(filter ...string) Intervals
StartTracking(tags []string) error
StopTracking() error
ContinueTracking() error
ContinueInterval(id int) error
CancelTracking() error
DeleteInterval(id int) error
FillInterval(id int) error
JoinInterval(id int) error
ModifyInterval(interval *Interval, adjust bool) error
GetSummary(filter ...string) string
GetActive() *Interval
Undo()
}
type TimewarriorInterop struct {
configLocation string
defaultArgs []string
config *TWConfig
ctx context.Context
mutex sync.Mutex
}
func NewTimewarriorInterop(ctx context.Context, configLocation string) *TimewarriorInterop {
if _, err := exec.LookPath(twBinary); err != nil {
slog.Error("Timewarrior not found")
return nil
}
ts := &TimewarriorInterop{
configLocation: configLocation,
defaultArgs: []string{},
ctx: ctx,
mutex: sync.Mutex{},
}
ts.config = ts.extractConfig()
return ts
}
func (ts *TimewarriorInterop) GetConfig() *TWConfig {
ts.mutex.Lock()
defer ts.mutex.Unlock()
return ts.config
}
func (ts *TimewarriorInterop) GetTags() []string {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"tags"}...)...)
output, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed getting tags", "error", err)
return nil
}
tags := make([]string, 0)
lines := strings.Split(string(output), "\n")
// Skip header lines and parse tag names
for i, line := range lines {
if i < 3 || line == "" {
continue
}
// Tags are space-separated, first column is the tag name
fields := strings.Fields(line)
if len(fields) > 0 {
tags = append(tags, fields[0])
}
}
slices.Sort(tags)
return tags
}
// GetTagCombinations returns unique tag combinations from intervals,
// ordered newest first (most recent intervals' tags appear first).
// Returns formatted strings like "dev client-work meeting".
func (ts *TimewarriorInterop) GetTagCombinations() []string {
intervals := ts.GetIntervals() // Already sorted newest first
// Track unique combinations while preserving order
seen := make(map[string]bool)
var combinations []string
for _, interval := range intervals {
if len(interval.Tags) == 0 {
continue // Skip intervals with no tags
}
// Format tags (handles spaces with quotes)
combo := formatTagsForCombination(interval.Tags)
if !seen[combo] {
seen[combo] = true
combinations = append(combinations, combo)
}
}
return combinations
}
// formatTagsForCombination formats tags consistently for display
func formatTagsForCombination(tags []string) string {
var formatted []string
for _, t := range tags {
if strings.Contains(t, " ") {
formatted = append(formatted, "\""+t+"\"")
} else {
formatted = append(formatted, t)
}
}
return strings.Join(formatted, " ")
}
// getIntervalsUnlocked fetches intervals without acquiring mutex (for internal use only).
// Caller must hold ts.mutex.
func (ts *TimewarriorInterop) getIntervalsUnlocked(filter ...string) Intervals {
args := append(ts.defaultArgs, "export")
if filter != nil {
args = append(args, filter...)
}
cmd := exec.CommandContext(ts.ctx, twBinary, args...)
output, err := cmd.CombinedOutput()
if err != nil {
if ts.ctx.Err() == context.Canceled {
return nil
}
slog.Error("Failed getting intervals", "error", err)
return nil
}
intervals := make(Intervals, 0)
err = json.Unmarshal(output, &intervals)
if err != nil {
slog.Error("Failed unmarshalling intervals", "error", err)
return nil
}
// Reverse the intervals to show newest first
slices.Reverse(intervals)
// Assign IDs based on new order (newest is @1)
for i := range intervals {
intervals[i].ID = i + 1
}
return intervals
}
// GetIntervals fetches intervals with the given filter, acquiring mutex lock.
func (ts *TimewarriorInterop) GetIntervals(filter ...string) Intervals {
ts.mutex.Lock()
defer ts.mutex.Unlock()
return ts.getIntervalsUnlocked(filter...)
}
func (ts *TimewarriorInterop) StartTracking(tags []string) error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
if len(tags) == 0 {
return fmt.Errorf("at least one tag is required")
}
args := append(ts.defaultArgs, "start")
args = append(args, tags...)
cmd := exec.CommandContext(ts.ctx, twBinary, args...)
if err := cmd.Run(); err != nil {
slog.Error("Failed starting tracking", "error", err)
return err
}
return nil
}
func (ts *TimewarriorInterop) StopTracking() error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "stop")...)
if err := cmd.Run(); err != nil {
slog.Error("Failed stopping tracking", "error", err)
return err
}
return nil
}
func (ts *TimewarriorInterop) ContinueTracking() error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "continue")...)
if err := cmd.Run(); err != nil {
slog.Error("Failed continuing tracking", "error", err)
return err
}
return nil
}
func (ts *TimewarriorInterop) ContinueInterval(id int) error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"continue", fmt.Sprintf("@%d", id)}...)...)
if err := cmd.Run(); err != nil {
slog.Error("Failed continuing interval", "error", err)
return err
}
return nil
}
func (ts *TimewarriorInterop) CancelTracking() error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "cancel")...)
if err := cmd.Run(); err != nil {
slog.Error("Failed canceling tracking", "error", err)
return err
}
return nil
}
func (ts *TimewarriorInterop) DeleteInterval(id int) error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"delete", fmt.Sprintf("@%d", id)}...)...)
if err := cmd.Run(); err != nil {
slog.Error("Failed deleting interval", "error", err)
return err
}
return nil
}
func (ts *TimewarriorInterop) FillInterval(id int) error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"fill", fmt.Sprintf("@%d", id)}...)...)
if err := cmd.Run(); err != nil {
slog.Error("Failed filling interval", "error", err)
return err
}
return nil
}
func (ts *TimewarriorInterop) JoinInterval(id int) error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
// Join the current interval with the previous one
// The previous interval has id+1 (since intervals are ordered newest first)
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"join", fmt.Sprintf("@%d", id+1), fmt.Sprintf("@%d", id)}...)...)
if err := cmd.Run(); err != nil {
slog.Error("Failed joining interval", "error", err)
return err
}
return nil
}
func (ts *TimewarriorInterop) ModifyInterval(interval *Interval, adjust bool) error {
ts.mutex.Lock()
defer ts.mutex.Unlock()
// Export the modified interval
intervals, err := json.Marshal(Intervals{interval})
if err != nil {
slog.Error("Failed marshalling interval", "error", err)
return err
}
// Build import command with optional :adjust hint
args := append(ts.defaultArgs, "import")
if adjust {
args = append(args, ":adjust")
}
// Import the modified interval
cmd := exec.CommandContext(ts.ctx, twBinary, args...)
cmd.Stdin = bytes.NewBuffer(intervals)
out, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed modifying interval", "error", err, "output", string(out))
return err
}
return nil
}
func (ts *TimewarriorInterop) GetSummary(filter ...string) string {
ts.mutex.Lock()
defer ts.mutex.Unlock()
args := append(ts.defaultArgs, "summary")
if filter != nil {
args = append(args, filter...)
}
cmd := exec.CommandContext(ts.ctx, twBinary, args...)
output, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed getting summary", "error", err)
return ""
}
return string(output)
}
func (ts *TimewarriorInterop) GetActive() *Interval {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"get", "dom.active"}...)...)
output, err := cmd.CombinedOutput()
if err != nil || string(output) == "0\n" {
return nil
}
// Get the active interval using unlocked version (we already hold the mutex)
intervals := ts.getIntervalsUnlocked()
for _, interval := range intervals {
if interval.End == "" {
return interval
}
}
return nil
}
func (ts *TimewarriorInterop) Undo() {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"undo"}...)...)
err := cmd.Run()
if err != nil {
slog.Error("Failed undoing", "error", err)
}
}
func (ts *TimewarriorInterop) extractConfig() *TWConfig {
cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"show"}...)...)
output, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed getting config", "error", err)
return nil
}
return NewConfig(strings.Split(string(output), "\n"))
}
func extractTags(config string) []string {
re := regexp.MustCompile(`tag\.([^.]+)\.[^.]+`)
matches := re.FindAllStringSubmatch(config, -1)
uniques := make(map[string]struct{})
for _, match := range matches {
uniques[match[1]] = struct{}{}
}
var tags []string
for tag := range uniques {
tags = append(tags, tag)
}
slices.Sort(tags)
return tags
}

56
main.go
View File

@@ -1,56 +0,0 @@
package main
import (
"context"
"fmt"
"log"
"log/slog"
"os"
"tasksquire/common"
"tasksquire/pages"
"tasksquire/taskwarrior"
tea "github.com/charmbracelet/bubbletea"
)
func main() {
ts := taskwarrior.NewTaskSquire("./test/taskrc")
ctx := context.Background()
common := common.NewCommon(ctx, ts)
file, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Fatalf("failed to open log file: %v", err)
}
defer file.Close()
// Create a new slog handler for the file
handler := slog.NewTextHandler(file, &slog.HandlerOptions{})
// Set the default logger to use the file handler
slog.SetDefault(slog.New(handler))
// form := huh.NewForm(
// huh.NewGroup(
// huh.NewSelect[string]().
// 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 := pages.NewMainPage(common)
if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
}

40
opencode_sandbox.sh Normal file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# 1. Resolve the absolute path of opencode from your Nix environment
OPENCODE_PATH=$(command -v opencode)
if [ -z "$OPENCODE_PATH" ]; then
echo "❌ Error: 'opencode' not found in your PATH."
exit 1
fi
echo "🛡️ Engaging Bubblewrap Sandbox..."
echo "📍 Using binary: $OPENCODE_PATH"
# 2. Run bwrap using the absolute path
bwrap \
--ro-bind /bin /bin \
--ro-bind /usr /usr \
--ro-bind /lib /lib \
--ro-bind /lib64 /lib64 \
--ro-bind /nix /nix \
--ro-bind /home/pan/.nix-profile/bin /home/pan/.nix-profile/bin \
--ro-bind /home/pan/.config/opencode /home/pan/.config/opencode \
--ro-bind /etc/resolv.conf /etc/resolv.conf \
--ro-bind /etc/hosts /etc/hosts \
--ro-bind-try /etc/ssl/certs /etc/ssl/certs \
--ro-bind-try /etc/static/ssl/certs /etc/static/ssl/certs \
--bind /home/pan/.local/share/opencode /home/pan/.local/share/opencode \
--proc /proc \
--dev-bind /dev /dev \
--tmpfs /tmp \
--unshare-all \
--share-net \
--die-with-parent \
--bind "$(pwd)" "$(pwd)" \
--chdir "$(pwd)" \
--setenv PATH "$PATH" \
--setenv HOME "$HOME" \
--setenv TASKRC "$TASKRC" \
--setenv TASKDATA "$TASKDATA" \
"$OPENCODE_PATH" "$@"

View File

@@ -1,110 +0,0 @@
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(),
}
selected := common.TW.GetActiveContext().Name
options := make([]string, 0)
for _, c := range p.contexts {
if c.Name != "none" {
options = append(options, c.Name)
}
}
slices.Sort(options)
options = append([]string{"(none)"}, 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).
WithTheme(p.common.Styles.Form)
return p
}
func (p *ContextPickerPage) SetSize(width, height int) {
p.common.SetSize(width, height)
}
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.WindowSizeMsg:
p.SetSize(msg.Width, msg.Height)
case tea.KeyMsg:
switch {
case key.Matches(msg, p.common.Keymap.Back):
model, err := p.common.PopPage()
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.PopPage()
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.Base.Render(p.form.View())
}
func (p *ContextPickerPage) updateContextCmd() tea.Msg {
context := p.form.GetString("context")
if context == "(none)" {
context = ""
}
return UpdateContextMsg(p.common.TW.GetContext(context))
}
type UpdateContextMsg *taskwarrior.Context

View File

@@ -1,122 +0,0 @@
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
// }

View File

@@ -1,46 +0,0 @@
package pages
import (
tea "github.com/charmbracelet/bubbletea"
"tasksquire/common"
)
type MainPage struct {
common *common.Common
activePage common.Component
}
func NewMainPage(common *common.Common) *MainPage {
m := &MainPage{
common: common,
}
m.activePage = NewReportPage(common, common.TW.GetReport(common.TW.GetConfig().Get("uda.tasksquire.report.default")))
// m.activePage = NewTaskEditorPage(common, taskwarrior.Task{})
return m
}
func (m *MainPage) Init() tea.Cmd {
return m.activePage.Init()
}
func (m *MainPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.common.SetSize(msg.Width, msg.Height)
}
activePage, cmd := m.activePage.Update(msg)
m.activePage = activePage.(common.Component)
return m, cmd
}
func (m *MainPage) View() string {
return m.activePage.View()
}

View File

@@ -1,69 +0,0 @@
package pages
import tea "github.com/charmbracelet/bubbletea"
type UpdatedTasksMsg struct{}
type nextColumnMsg struct{}
func nextColumn() tea.Cmd {
return func() tea.Msg {
return nextColumnMsg{}
}
}
type prevColumnMsg struct{}
func prevColumn() tea.Cmd {
return func() tea.Msg {
return prevColumnMsg{}
}
}
type nextFieldMsg struct{}
func nextField() tea.Cmd {
return func() tea.Msg {
return nextFieldMsg{}
}
}
type prevFieldMsg struct{}
func prevField() tea.Cmd {
return func() tea.Msg {
return prevFieldMsg{}
}
}
type nextAreaMsg struct{}
func nextArea() tea.Cmd {
return func() tea.Msg {
return nextAreaMsg{}
}
}
type prevAreaMsg struct{}
func prevArea() tea.Cmd {
return func() tea.Msg {
return prevAreaMsg{}
}
}
type changeAreaMsg area
func changeArea(a area) tea.Cmd {
return func() tea.Msg {
return changeAreaMsg(a)
}
}
func changeMode(mode mode) tea.Cmd {
return func() tea.Msg {
return changeModeMsg(mode)
}
}
type changeModeMsg mode

View File

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

View File

@@ -1,106 +0,0 @@
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) SetSize(width, height int) {
p.common.SetSize(width, height)
}
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.WindowSizeMsg:
p.SetSize(msg.Width, msg.Height)
case tea.KeyMsg:
switch {
case key.Matches(msg, p.common.Keymap.Back):
model, err := p.common.PopPage()
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.PopPage()
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.Base.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

View File

@@ -1,218 +0,0 @@
// TODO: update table every second (to show correct relative time)
package pages
import (
"tasksquire/common"
"tasksquire/components/table"
"tasksquire/taskwarrior"
"github.com/charmbracelet/bubbles/key"
// "github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
)
type ReportPage struct {
common *common.Common
activeReport *taskwarrior.Report
activeContext *taskwarrior.Context
activeProject string
selectedTask *taskwarrior.Task
taskCursor int
tasks taskwarrior.Tasks
taskTable table.Model
subpage tea.Model
}
func NewReportPage(com *common.Common, report *taskwarrior.Report) *ReportPage {
// return &ReportPage{
// common: com,
// activeReport: report,
// activeContext: com.TW.GetActiveContext(),
// activeProject: "",
// taskTable: table.New(com),
// }
p := &ReportPage{
common: com,
activeReport: report,
activeContext: com.TW.GetActiveContext(),
activeProject: "",
taskTable: table.New(com),
}
p.subpage = NewTaskEditorPage(p.common, taskwarrior.Task{})
p.subpage.Init()
p.common.PushPage(p)
return p
}
func (p *ReportPage) SetSize(width int, height int) {
p.common.SetSize(width, height)
p.taskTable.SetWidth(width - p.common.Styles.Base.GetVerticalFrameSize())
p.taskTable.SetHeight(height - p.common.Styles.Base.GetHorizontalFrameSize())
}
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 tea.WindowSizeMsg:
p.SetSize(msg.Width, msg.Height)
// case BackMsg:
case TaskMsg:
p.tasks = taskwarrior.Tasks(msg)
p.populateTaskTable(p.tasks)
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 UpdatedTasksMsg:
cmds = append(cmds, p.getTasks())
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.common.PushPage(p)
return p.subpage, nil
case key.Matches(msg, p.common.Keymap.SetContext):
p.subpage = NewContextPickerPage(p.common)
p.subpage.Init()
p.common.PushPage(p)
return p.subpage, nil
case key.Matches(msg, p.common.Keymap.Add):
p.subpage = NewTaskEditorPage(p.common, taskwarrior.Task{})
p.subpage.Init()
p.common.PushPage(p)
return p.subpage, nil
case key.Matches(msg, p.common.Keymap.Edit):
p.subpage = NewTaskEditorPage(p.common, *p.selectedTask)
p.subpage.Init()
p.common.PushPage(p)
return p.subpage, nil
case key.Matches(msg, p.common.Keymap.Ok):
p.common.TW.SetTaskDone(p.selectedTask)
return p, p.getTasks()
case key.Matches(msg, p.common.Keymap.Delete):
p.common.TW.DeleteTask(p.selectedTask)
return p, p.getTasks()
case key.Matches(msg, p.common.Keymap.SetProject):
p.subpage = NewProjectPickerPage(p.common, p.activeProject)
p.subpage.Init()
p.common.PushPage(p)
return p.subpage, nil
case key.Matches(msg, p.common.Keymap.Tag):
if p.selectedTask != nil {
tag := p.common.TW.GetConfig().Get("uda.tasksquire.tag.default")
if p.selectedTask.HasTag(tag) {
p.selectedTask.RemoveTag(tag)
} else {
p.selectedTask.AddTag(tag)
}
p.common.TW.ImportTask(p.selectedTask)
return p, p.getTasks()
}
return p, nil
case key.Matches(msg, p.common.Keymap.Undo):
p.common.TW.Undo()
return p, p.getTasks()
case key.Matches(msg, p.common.Keymap.StartStop):
if p.selectedTask != nil && p.selectedTask.Status == "pending" {
if p.selectedTask.Start == "" {
p.common.TW.StartTask(p.selectedTask)
} else {
p.common.TW.StopTask(p.selectedTask)
}
return p, p.getTasks()
}
}
}
var cmd tea.Cmd
p.taskTable, cmd = p.taskTable.Update(msg)
cmds = append(cmds, cmd)
if p.tasks != nil && len(p.tasks) > 0 {
p.selectedTask = p.tasks[p.taskTable.Cursor()]
} else {
p.selectedTask = nil
}
return p, tea.Batch(cmds...)
}
func (p *ReportPage) View() string {
// return p.common.Styles.Main.Render(p.taskTable.View()) + "\n"
if p.tasks == nil || len(p.tasks) == 0 {
return p.common.Styles.Base.Render("No tasks found")
}
return p.taskTable.View()
}
func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) {
if len(tasks) == 0 {
return
}
selected := p.taskTable.Cursor()
if p.selectedTask != nil {
for i, task := range tasks {
if task.Uuid == p.selectedTask.Uuid {
selected = i
}
}
}
if selected > len(tasks)-1 {
selected = len(tasks) - 1
}
p.taskTable = table.New(
p.common,
table.WithReport(p.activeReport),
table.WithTasks(tasks),
table.WithFocused(true),
table.WithWidth(p.common.Width()-p.common.Styles.Base.GetVerticalFrameSize()),
table.WithHeight(p.common.Height()-p.common.Styles.Base.GetHorizontalFrameSize()-1),
table.WithStyles(p.common.Styles.TableStyle),
)
if selected == 0 {
selected = p.taskTable.Cursor()
}
if selected < len(tasks) {
p.taskTable.SetCursor(selected)
} else {
p.taskTable.SetCursor(len(p.tasks) - 1)
}
}
func (p *ReportPage) getTasks() tea.Cmd {
return func() tea.Msg {
filters := []string{}
if p.activeProject != "" {
filters = append(filters, "project:"+p.activeProject)
}
tasks := p.common.TW.GetTasks(p.activeReport, filters...)
return TaskMsg(tasks)
}
}
type TaskMsg taskwarrior.Tasks

View File

@@ -1,103 +0,0 @@
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) SetSize(width, height int) {
p.common.SetSize(width, height)
}
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.WindowSizeMsg:
p.SetSize(msg.Width, msg.Height)
case tea.KeyMsg:
switch {
case key.Matches(msg, p.common.Keymap.Back):
model, err := p.common.PopPage()
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.PopPage()
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.Base.Render(p.form.View())
}
func (p *ReportPickerPage) updateReportCmd() tea.Msg {
return UpdateReportMsg(p.common.TW.GetReport(p.form.GetString("report")))
}
type UpdateReportMsg *taskwarrior.Report

View File

@@ -1,912 +0,0 @@
package pages
import (
"fmt"
"log/slog"
"slices"
"strings"
"tasksquire/common"
"tasksquire/taskwarrior"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
)
type mode int
const (
modeNormal mode = iota
modeInsert
)
type TaskEditorPage struct {
common *common.Common
task taskwarrior.Task
mode mode
columnCursor int
area area
areaPicker *areaPicker
areas map[area]tea.Model
}
func NewTaskEditorPage(com *common.Common, task taskwarrior.Task) *TaskEditorPage {
p := TaskEditorPage{
common: com,
task: task,
}
if p.task.Priority == "" {
p.task.Priority = "(none)"
}
if p.task.Project == "" {
p.task.Project = "(none)"
}
priorityOptions := append([]string{"(none)"}, p.common.TW.GetPriorities()...)
projectOptions := append([]string{"(none)"}, p.common.TW.GetProjects()...)
tagOptions := p.common.TW.GetTags()
tagOptions = append(tagOptions, strings.Split(p.common.TW.GetConfig().Get("uda.tasksquire.tags.default"), ",")...)
slices.Sort(tagOptions)
p.areas = map[area]tea.Model{
areaTask: NewTaskEdit(p.common, &p.task.Description, &p.task.Priority, &p.task.Project, priorityOptions, projectOptions),
areaTags: NewTagEdit(p.common, &p.task.Tags, tagOptions),
areaTime: NewTimeEdit(p.common, &p.task.Due, &p.task.Scheduled, &p.task.Wait, &p.task.Until),
}
// p.areaList = NewAreaList(common, areaItems)
// p.selectedArea = areaTask
// p.columns = append([]tea.Model{p.areaList}, p.areas[p.selectedArea]...)
p.areaPicker = NewAreaPicker(com, []string{"Task", "Tags", "Dates"})
p.columnCursor = 1
if p.task.Uuid == "" {
// p.mode = modeInsert
p.mode = modeInsert
} else {
p.mode = modeNormal
}
return &p
}
func (p *TaskEditorPage) SetSize(width, height int) {
p.common.SetSize(width, height)
}
func (p *TaskEditorPage) Init() tea.Cmd {
return nil
}
func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case changeAreaMsg:
p.area = area(msg)
case changeModeMsg:
p.mode = mode(msg)
case prevColumnMsg:
p.columnCursor--
if p.columnCursor < 0 {
p.columnCursor = len(p.areas) - 1
}
case nextColumnMsg:
p.columnCursor++
if p.columnCursor > len(p.areas)-1 {
p.columnCursor = 0
}
case prevAreaMsg:
p.area--
if p.area < 0 {
p.area = 2
}
case nextAreaMsg:
p.area++
if p.area > 2 {
p.area = 0
}
}
switch p.mode {
case modeNormal:
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, p.common.Keymap.Back):
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, BackCmd
case key.Matches(msg, p.common.Keymap.Insert):
return p, changeMode(modeInsert)
case key.Matches(msg, p.common.Keymap.Ok):
model, err := p.common.PopPage()
if err != nil {
slog.Error("page stack empty")
return nil, tea.Quit
}
return model, p.updateTasksCmd
case key.Matches(msg, p.common.Keymap.Left):
return p, prevColumn()
case key.Matches(msg, p.common.Keymap.Right):
return p, nextColumn()
case key.Matches(msg, p.common.Keymap.Up):
var cmd tea.Cmd
if p.columnCursor == 0 {
picker, cmd := p.areaPicker.Update(msg)
p.areaPicker = picker.(*areaPicker)
return p, cmd
} else {
p.areas[p.area], cmd = p.areas[p.area].Update(prevFieldMsg{})
return p, cmd
}
case key.Matches(msg, p.common.Keymap.Down):
var cmd tea.Cmd
if p.columnCursor == 0 {
picker, cmd := p.areaPicker.Update(msg)
p.areaPicker = picker.(*areaPicker)
return p, cmd
} else {
p.areas[p.area], cmd = p.areas[p.area].Update(nextFieldMsg{})
return p, cmd
}
}
}
// var cmd tea.Cmd
// if p.columnCursor == 0 {
// p., cmd = p.areaList.Update(msg)
// p.selectedArea = p.areaList.(areaList).Area()
// cmds = append(cmds, cmd)
// } else {
// p.areas[p.selectedArea][p.columnCursor-1], cmd = p.areas[p.selectedArea][p.columnCursor-1].Update(msg)
// cmds = append(cmds, cmd)
// }
case modeInsert:
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, p.common.Keymap.Back):
return p, changeMode(modeNormal)
case key.Matches(msg, p.common.Keymap.Ok):
area, cmd := p.areas[p.area].Update(msg)
p.areas[p.area] = area
return p, tea.Batch(cmd, nextField())
}
}
var cmd tea.Cmd
if p.columnCursor == 0 {
picker, cmd := p.areaPicker.Update(msg)
p.areaPicker = picker.(*areaPicker)
return p, cmd
} else {
p.areas[p.area], cmd = p.areas[p.area].Update(msg)
return p, cmd
}
}
return p, nil
}
func (p *TaskEditorPage) View() string {
var focusStyle lipgloss.Style
if p.mode == modeInsert {
focusStyle = p.common.Styles.ColumnInsert
} else {
focusStyle = p.common.Styles.ColumnFocused
}
var picker, area string
if p.columnCursor == 0 {
picker = focusStyle.Render(p.areaPicker.View())
area = p.common.Styles.ColumnBlurred.Render(p.areas[p.area].View())
} else {
picker = p.common.Styles.ColumnBlurred.Render(p.areaPicker.View())
area = focusStyle.Render(p.areas[p.area].View())
}
return lipgloss.JoinHorizontal(
lipgloss.Left,
picker,
area,
)
}
// import (
// "fmt"
// "io"
// "log/slog"
// "strings"
// "tasksquire/common"
// "tasksquire/taskwarrior"
// "time"
// "github.com/charmbracelet/bubbles/list"
// "github.com/charmbracelet/bubbles/textinput"
// tea "github.com/charmbracelet/bubbletea"
// "github.com/charmbracelet/huh"
// "github.com/charmbracelet/lipgloss"
// )
// type Field int
// const (
// FieldDescription Field = iota
// FieldPriority
// FieldProject
// FieldNewProject
// FieldTags
// FieldNewTags
// FieldDue
// FieldScheduled
// FieldWait
// FieldUntil
// )
// type column int
// const (
// column1 column = iota
// column2
// column3
// )
// func changeColumn(c column) tea.Cmd {
// return func() tea.Msg {
// return changeColumnMsg(c)
// }
// }
// type changeColumnMsg column
// type mode int
// const (
// modeNormal mode = iota
// modeInsert
// modeAddTag
// modeAddProject
// )
// type TaskEditorPage struct {
// common *common.Common
// task taskwarrior.Task
// areaList tea.Model
// mode mode
// statusline tea.Model
// // TODO: rework support for adding tags and projects
// additionalTags string
// additionalProject string
// columnCursor int
// columns []tea.Model
// areas map[area][]tea.Model
// selectedArea area
// }
// 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.Uuid == "" {
// p.mode = modeInsert
// } else {
// p.mode = modeNormal
// }
// areaItems := []list.Item{
// item("Task"),
// item("Tags"),
// item("Time"),
// }
// }
// p.statusline = NewStatusLine(common, p.mode)
// return p
// }
type area int
const (
areaTask area = iota
areaTags
areaTime
)
type areaPicker struct {
common *common.Common
list list.Model
}
type item string
func (i item) Title() string { return string(i) }
func (i item) Description() string { return "test" }
func (i item) FilterValue() string { return "" }
func NewAreaPicker(common *common.Common, items []string) *areaPicker {
listItems := make([]list.Item, len(items))
for i, itm := range items {
listItems[i] = item(itm)
}
list := list.New(listItems, list.DefaultDelegate{}, 20, 50)
list.SetFilteringEnabled(false)
list.SetShowStatusBar(false)
return &areaPicker{
common: common,
list: list,
}
}
func (a *areaPicker) Area() area {
switch a.list.SelectedItem() {
case item("Task"):
return areaTask
case item("Tags"):
return areaTags
case item("Dates"):
return areaTime
default:
return areaTask
}
}
func (a *areaPicker) Init() tea.Cmd {
return nil
}
func (a *areaPicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
var cmd tea.Cmd
cursor := a.list.Cursor()
// switch msg.(type) {
// case nextFieldMsg:
// a.list, cmd = a.list.Update(a.list.KeyMap.CursorDown)
// case prevFieldMsg:
// a.list, cmd = a.list.Update(a.list.KeyMap.CursorUp)
// }
a.list, cmd = a.list.Update(msg)
cmds = append(cmds, cmd)
if cursor != a.list.Cursor() {
cmds = append(cmds, changeArea(a.Area()))
}
return a, tea.Batch(cmds...)
}
func (a *areaPicker) View() string {
return a.list.View()
}
type taskEdit struct {
common *common.Common
fields []huh.Field
cursor int
newProjectName *string
}
func NewTaskEdit(common *common.Common, description *string, priority *string, project *string, priorityOptions []string, projectOptions []string) *taskEdit {
newProject := ""
defaultKeymap := huh.NewDefaultKeyMap()
t := taskEdit{
common: common,
fields: []huh.Field{
huh.NewInput().
Title("Task").
Value(description).
Validate(func(desc string) error {
if desc == "" {
return fmt.Errorf("task description is required")
}
return nil
}).
Inline(true).
WithTheme(common.Styles.Form),
huh.NewSelect[string]().
Options(huh.NewOptions(priorityOptions...)...).
Title("Priority").
Key("priority").
Value(priority).
WithKeyMap(defaultKeymap).
WithTheme(common.Styles.Form),
huh.NewSelect[string]().
Options(huh.NewOptions(projectOptions...)...).
Title("Project").
Value(project).
WithKeyMap(defaultKeymap).
WithTheme(common.Styles.Form),
huh.NewInput().
Title("New Project").
Value(&newProject).
WithTheme(common.Styles.Form),
},
newProjectName: &newProject,
}
t.fields[0].Focus()
return &t
}
func (t taskEdit) Init() tea.Cmd {
return nil
}
func (t taskEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) {
case nextFieldMsg:
if t.cursor == len(t.fields)-1 {
t.fields[t.cursor].Blur()
return t, nextArea()
}
t.fields[t.cursor].Blur()
t.cursor++
t.fields[t.cursor].Focus()
case prevFieldMsg:
if t.cursor == 0 {
t.fields[t.cursor].Blur()
return t, prevArea()
}
t.fields[t.cursor].Blur()
t.cursor--
t.fields[t.cursor].Focus()
default:
field, cmd := t.fields[t.cursor].Update(msg)
t.fields[t.cursor] = field.(huh.Field)
return t, cmd
}
return t, nil
}
func (t taskEdit) View() string {
views := make([]string, len(t.fields))
for i, field := range t.fields {
views[i] = field.View()
}
return lipgloss.JoinVertical(
lipgloss.Left,
views...,
)
}
type tagEdit struct {
common *common.Common
fields []huh.Field
cursor int
newTagsValue *string
}
func NewTagEdit(common *common.Common, selected *[]string, options []string) *tagEdit {
newTags := ""
defaultKeymap := huh.NewDefaultKeyMap()
t := tagEdit{
common: common,
fields: []huh.Field{
huh.NewMultiSelect[string]().
Options(huh.NewOptions(options...)...).
// Key("tags").
Title("Tags").
Value(selected).
Filterable(true).
WithKeyMap(defaultKeymap).
WithTheme(common.Styles.Form),
huh.NewInput().
Title("New Tags").
Value(&newTags).
Inline(true).
WithTheme(common.Styles.Form),
},
newTagsValue: &newTags,
}
t.fields[0].Focus()
return &t
}
func (t tagEdit) Init() tea.Cmd {
return nil
}
func (t tagEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) {
case nextFieldMsg:
if t.cursor == len(t.fields)-1 {
t.fields[t.cursor].Blur()
return t, nextArea()
}
t.fields[t.cursor].Blur()
t.cursor++
t.fields[t.cursor].Focus()
case prevFieldMsg:
if t.cursor == 0 {
t.fields[t.cursor].Blur()
return t, prevArea()
}
t.fields[t.cursor].Blur()
t.cursor--
t.fields[t.cursor].Focus()
default:
field, cmd := t.fields[t.cursor].Update(msg)
t.fields[t.cursor] = field.(huh.Field)
return t, cmd
}
return t, nil
}
func (t tagEdit) View() string {
views := make([]string, len(t.fields))
for i, field := range t.fields {
views[i] = field.View()
}
return lipgloss.JoinVertical(
lipgloss.Left,
views...,
)
}
type timeEdit struct {
common *common.Common
fields []huh.Field
cursor int
}
func NewTimeEdit(common *common.Common, due *string, scheduled *string, wait *string, until *string) *timeEdit {
// defaultKeymap := huh.NewDefaultKeyMap()
t := timeEdit{
common: common,
fields: []huh.Field{
huh.NewInput().
Title("Due").
Value(due).
Validate(taskwarrior.ValidateDate).
Inline(true).
WithTheme(common.Styles.Form),
huh.NewInput().
Title("Scheduled").
Value(scheduled).
Validate(taskwarrior.ValidateDate).
Inline(true).
WithTheme(common.Styles.Form),
huh.NewInput().
Title("Wait").
Value(wait).
Validate(taskwarrior.ValidateDate).
Inline(true).
WithTheme(common.Styles.Form),
huh.NewInput().
Title("Until").
Value(until).
Validate(taskwarrior.ValidateDate).
Inline(true).
WithTheme(common.Styles.Form),
},
}
t.fields[0].Focus()
return &t
}
func (t timeEdit) Init() tea.Cmd {
return nil
}
func (t timeEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) {
case nextFieldMsg:
if t.cursor == len(t.fields)-1 {
t.fields[t.cursor].Blur()
return t, nextArea()
}
t.fields[t.cursor].Blur()
t.cursor++
t.fields[t.cursor].Focus()
case prevFieldMsg:
if t.cursor == 0 {
t.fields[t.cursor].Blur()
return t, prevArea()
}
t.fields[t.cursor].Blur()
t.cursor--
t.fields[t.cursor].Focus()
default:
field, cmd := t.fields[t.cursor].Update(msg)
t.fields[t.cursor] = field.(huh.Field)
return t, cmd
}
return t, nil
}
func (t timeEdit) View() string {
views := make([]string, len(t.fields))
for i, field := range t.fields {
views[i] = field.View()
}
return lipgloss.JoinVertical(
lipgloss.Left,
views...,
)
}
// func (p *TaskEditorPage) SetSize(width, height int) {
// p.common.SetSize(width, height)
// }
// func (p *TaskEditorPage) Init() tea.Cmd {
// // return p.form.Init()
// return nil
// }
// func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// var cmds []tea.Cmd
// switch msg := msg.(type) {
// case SwitchModeMsg:
// switch mode(msg) {
// case modeNormal:
// p.mode = modeNormal
// case modeInsert:
// p.mode = modeInsert
// }
// case changeAreaMsg:
// p.selectedArea = area(msg)
// p.columns = append([]tea.Model{p.areaList}, p.areas[p.selectedArea]...)
// case nextColumnMsg:
// p.columnCursor++
// if p.columnCursor > len(p.columns)-1 {
// p.columnCursor = 0
// }
// case prevColumnMsg:
// p.columnCursor--
// if p.columnCursor < 0 {
// p.columnCursor = len(p.columns) - 1
// }
// }
// switch p.mode {
// case modeNormal:
// switch msg := msg.(type) {
// case tea.KeyMsg:
// switch {
// case key.Matches(msg, p.common.Keymap.Back):
// model, err := p.common.PopPage()
// if err != nil {
// slog.Error("page stack empty")
// return nil, tea.Quit
// }
// return model, BackCmd
// case key.Matches(msg, p.common.Keymap.Insert):
// return p, p.switchModeCmd(modeInsert)
// // case key.Matches(msg, p.common.Keymap.Ok):
// // p.form.State = huh.StateCompleted
// case key.Matches(msg, p.common.Keymap.Left):
// return p, prevColumn()
// case key.Matches(msg, p.common.Keymap.Right):
// return p, nextColumn()
// }
// }
// case modeInsert:
// switch msg := msg.(type) {
// case tea.KeyMsg:
// switch {
// case key.Matches(msg, p.common.Keymap.Back):
// return p, p.switchModeCmd(modeNormal)
// }
// }
// var cmd tea.Cmd
// if p.columnCursor == 0 {
// p.areaList, cmd = p.areaList.Update(msg)
// p.selectedArea = p.areaList.(areaList).Area()
// cmds = append(cmds, cmd)
// } else {
// p.areas[p.selectedArea][p.columnCursor-1], cmd = p.areas[p.selectedArea][p.columnCursor-1].Update(msg)
// cmds = append(cmds, cmd)
// }
// }
// var cmd tea.Cmd
// if p.columnCursor == 0 {
// p.areaList, cmd = p.areaList.Update(msg)
// p.selectedArea = p.areaList.(areaList).Area()
// cmds = append(cmds, cmd)
// } else {
// p.areas[p.selectedArea][p.columnCursor-1], cmd = p.areas[p.selectedArea][p.columnCursor-1].Update(msg)
// cmds = append(cmds, cmd)
// }
// p.statusline, cmd = p.statusline.Update(msg)
// cmds = append(cmds, cmd)
// // if p.form.State == huh.StateCompleted {
// // cmds = append(cmds, p.updateTasksCmd)
// // model, err := p.common.PopPage()
// // 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 {
// columns := make([]string, len(p.columns))
// for i, c := range p.columns {
// columns[i] = c.View()
// }
// return lipgloss.JoinVertical(
// lipgloss.Left,
// lipgloss.JoinHorizontal(
// lipgloss.Top,
// // lipgloss.Place(p.common.Width(), p.common.Height()-1, 0.5, 0.5, p.form.View(), lipgloss.WithWhitespaceBackground(p.common.Styles.Warning.GetForeground())),
// // lipgloss.Place(p.common.Width(), p.common.Height()-1, 0.5, 0.5, p.form.View(), lipgloss.WithWhitespaceChars(" . ")),
// columns...,
// ),
// p.statusline.View(),
// )
// }
func (p *TaskEditorPage) updateTasksCmd() tea.Msg {
if p.task.Project == "(none)" {
p.task.Project = ""
}
if p.task.Priority == "(none)" {
p.task.Priority = ""
}
if *p.areas[areaTask].(taskEdit).newProjectName != "" {
p.task.Project = *p.areas[areaTask].(taskEdit).newProjectName
}
if *p.areas[areaTags].(tagEdit).newTagsValue != "" {
p.task.Tags = append(p.task.Tags, strings.Split(*p.areas[areaTags].(tagEdit).newTagsValue, " ")...)
}
// if p.additionalProject != "" {
// p.task.Project = p.additionalProject
// }
// tags := p.form.Get("tags").([]string)
// p.task.Tags = tags
p.common.TW.ImportTask(&p.task)
return UpdatedTasksMsg{}
}
// type StatusLine struct {
// common *common.Common
// mode mode
// input textinput.Model
// }
// func NewStatusLine(common *common.Common, mode mode) *StatusLine {
// input := textinput.New()
// input.Placeholder = ""
// input.Prompt = ""
// input.Blur()
// return &StatusLine{
// input: textinput.New(),
// common: common,
// mode: mode,
// }
// }
// func (s *StatusLine) Init() tea.Cmd {
// s.input.Blur()
// return nil
// }
// func (s *StatusLine) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// var cmd tea.Cmd
// switch msg := msg.(type) {
// case SwitchModeMsg:
// s.mode = mode(msg)
// switch s.mode {
// case modeNormal:
// s.input.Blur()
// case modeInsert:
// s.input.Focus()
// }
// case tea.KeyMsg:
// switch {
// case key.Matches(msg, s.common.Keymap.Back):
// s.input.Blur()
// case key.Matches(msg, s.common.Keymap.Input):
// s.input.Focus()
// }
// }
// s.input, cmd = s.input.Update(msg)
// return s, cmd
// }
// func (s *StatusLine) View() string {
// var mode string
// switch s.mode {
// case modeNormal:
// mode = s.common.Styles.Base.Render("NORMAL")
// case modeInsert:
// mode = s.common.Styles.Active.Inline(true).Reverse(true).Render("INSERT")
// }
// return lipgloss.JoinHorizontal(lipgloss.Left, mode, s.input.View())
// }
// // TODO: move this to taskwarrior; add missing date formats
// type itemDelegate struct{}
// func (d itemDelegate) Height() int { return 1 }
// func (d itemDelegate) Spacing() int { return 0 }
// func (d itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil }
// func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
// i, ok := listItem.(item)
// if !ok {
// return
// }
// str := fmt.Sprintf("%s", i)
// fn := itemStyle.Render
// if index == m.Index() {
// fn = func(s ...string) string {
// return selectedItemStyle.Render("> " + strings.Join(s, " "))
// }
// }
// fmt.Fprint(w, fn(str))
// }
// var (
// titleStyle = lipgloss.NewStyle().MarginLeft(2)
// itemStyle = lipgloss.NewStyle().PaddingLeft(4)
// selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("170"))
// paginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4)
// helpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1)
// quitTextStyle = lipgloss.NewStyle().Margin(1, 0, 2, 4)
// )
// type item string
// func (i item) FilterValue() string { return "" }

View File

@@ -1,534 +0,0 @@
// TODO: error handling
// TODO: split combinedOutput and handle stderr differently
// TODO: reorder functions
package taskwarrior
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"os/exec"
"regexp"
"slices"
"strings"
"sync"
)
const (
twBinary = "task"
)
var (
nonStandardReports = map[string]struct{}{
"burndown.daily": {},
"burndown.monthly": {},
"burndown.weekly": {},
"calendar": {},
"colors": {},
"export": {},
"ghistory.annual": {},
"ghistory.monthly": {},
"history.annual": {},
"history.monthly": {},
"information": {},
"summary": {},
"timesheet": {},
}
virtualTags = 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
ImportTask(task *Task)
SetTaskDone(task *Task)
DeleteTask(task *Task)
StartTask(task *Task)
StopTask(task *Task)
Undo()
}
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)
break
}
}
}
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
}
for _, task := range tasks {
if task.Depends != nil && len(task.Depends) > 0 {
ids := make([]string, len(task.Depends))
for i, dependUuid := range task.Depends {
ids[i] = ts.getIds([]string{fmt.Sprintf("uuid:%s", dependUuid)})
}
task.DependsIds = strings.Join(ids, " ")
}
}
return tasks
}
func (ts *TaskSquire) getIds(filter []string) string {
cmd := exec.Command(twBinary, append(append(ts.defaultArgs, filter...), "_ids")...)
out, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed getting field:", err)
return ""
}
return strings.TrimSpace(string(out))
}
func (ts *TaskSquire) GetContext(context string) *Context {
ts.mutex.Lock()
defer ts.mutex.Unlock()
if context == "" {
context = "none"
}
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 := virtualTags[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()
if context.Name == "none" && ts.contexts["none"].Active {
return nil
}
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
}
// TODO error handling
func (ts *TaskSquire) ImportTask(task *Task) {
ts.mutex.Lock()
defer ts.mutex.Unlock()
tasks, err := json.Marshal(Tasks{task})
if err != nil {
slog.Error("Failed marshalling task:", err)
}
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"import", "-"}...)...)
cmd.Stdin = bytes.NewBuffer(tasks)
out, err := cmd.CombinedOutput()
if err != nil {
slog.Error("Failed modifying task:", err, string(out))
}
}
func (ts *TaskSquire) SetTaskDone(task *Task) {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"done", task.Uuid}...)...)
err := cmd.Run()
if err != nil {
slog.Error("Failed setting task done:", err)
}
}
func (ts *TaskSquire) DeleteTask(task *Task) {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{task.Uuid, "delete"}...)...)
err := cmd.Run()
if err != nil {
slog.Error("Failed deleting task:", err)
}
}
func (ts *TaskSquire) Undo() {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"undo"}...)...)
err := cmd.Run()
if err != nil {
slog.Error("Failed undoing task:", err)
}
}
func (ts *TaskSquire) StartTask(task *Task) {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"start", task.Uuid}...)...)
err := cmd.Run()
if err != nil {
slog.Error("Failed starting task:", err)
}
}
func (ts *TaskSquire) StopTask(task *Task) {
ts.mutex.Lock()
defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"stop", task.Uuid}...)...)
err := cmd.Run()
if err != nil {
slog.Error("Failed stopping task:", err)
}
}
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 {
if _, ok := nonStandardReports[report]; ok {
continue
}
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
}

Binary file not shown.

View File

@@ -1,6 +0,0 @@
include light-256.theme
context.test.read=+test
context.test.write=+test
context.home.read=+home
context.home.write=+home