Add offline-first task-master TUI with tickets, board lists, and undo.
Charm-based CLI with JSON persistence, status/list filters, external editor editing, command-history undo/redo, and glamour markdown without AutoStyle OSC hangs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
package ticket
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
ticketsFileName = "tickets.json"
|
||||
legacyFileName = "tasks.json"
|
||||
)
|
||||
|
||||
// JSONStore persists tickets as JSON arrays under DataDir/projects/<id>/tickets.json.
|
||||
type JSONStore struct {
|
||||
dataDir string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewJSONStore creates a store rooted at dataDir.
|
||||
func NewJSONStore(dataDir string) *JSONStore {
|
||||
return &JSONStore{dataDir: dataDir}
|
||||
}
|
||||
|
||||
func (s *JSONStore) projectPath(projectID string) string {
|
||||
return filepath.Join(s.dataDir, "projects", projectID)
|
||||
}
|
||||
|
||||
func (s *JSONStore) ticketsPath(projectID string) string {
|
||||
return filepath.Join(s.projectPath(projectID), ticketsFileName)
|
||||
}
|
||||
|
||||
func (s *JSONStore) legacyPath(projectID string) string {
|
||||
return filepath.Join(s.projectPath(projectID), legacyFileName)
|
||||
}
|
||||
|
||||
// List returns all tickets for a project.
|
||||
func (s *JSONStore) List(projectID string) ([]Ticket, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.readUnlocked(projectID)
|
||||
}
|
||||
|
||||
// Get finds a ticket by ID across all project directories.
|
||||
func (s *JSONStore) Get(id string) (Ticket, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
projects, err := os.ReadDir(filepath.Join(s.dataDir, "projects"))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return Ticket{}, fmt.Errorf("ticket %s: not found", id)
|
||||
}
|
||||
return Ticket{}, fmt.Errorf("list projects: %w", err)
|
||||
}
|
||||
|
||||
for _, p := range projects {
|
||||
if !p.IsDir() {
|
||||
continue
|
||||
}
|
||||
tickets, err := s.readUnlocked(p.Name())
|
||||
if err != nil {
|
||||
return Ticket{}, err
|
||||
}
|
||||
for _, t := range tickets {
|
||||
if t.ID == id {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ticket{}, fmt.Errorf("ticket %s: not found", id)
|
||||
}
|
||||
|
||||
// Create adds a new ticket, assigning ID and timestamps if missing.
|
||||
func (s *JSONStore) Create(t Ticket) (Ticket, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if t.ProjectID == "" {
|
||||
return Ticket{}, fmt.Errorf("projectId is required")
|
||||
}
|
||||
if t.Title == "" {
|
||||
return Ticket{}, fmt.Errorf("title is required")
|
||||
}
|
||||
if t.ID == "" {
|
||||
t.ID = uuid.NewString()
|
||||
}
|
||||
if t.Status == "" {
|
||||
t.Status = StatusTodo
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if t.CreatedAt.IsZero() {
|
||||
t.CreatedAt = now
|
||||
}
|
||||
if t.UpdatedAt.IsZero() {
|
||||
t.UpdatedAt = now
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(s.projectPath(t.ProjectID), 0o755); err != nil {
|
||||
return Ticket{}, fmt.Errorf("create project dir: %w", err)
|
||||
}
|
||||
|
||||
tickets, err := s.readUnlocked(t.ProjectID)
|
||||
if err != nil {
|
||||
return Ticket{}, err
|
||||
}
|
||||
tickets = append(tickets, t)
|
||||
if err := s.writeUnlocked(t.ProjectID, tickets); err != nil {
|
||||
return Ticket{}, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Update replaces an existing ticket (matched by ID within its project).
|
||||
func (s *JSONStore) Update(t Ticket) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if t.ID == "" || t.ProjectID == "" {
|
||||
return fmt.Errorf("id and projectId are required")
|
||||
}
|
||||
|
||||
tickets, err := s.readUnlocked(t.ProjectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
found := false
|
||||
for i := range tickets {
|
||||
if tickets[i].ID == t.ID {
|
||||
t.CreatedAt = tickets[i].CreatedAt
|
||||
t.UpdatedAt = time.Now().UTC()
|
||||
tickets[i] = t
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("ticket %s: not found", t.ID)
|
||||
}
|
||||
return s.writeUnlocked(t.ProjectID, tickets)
|
||||
}
|
||||
|
||||
// Delete removes a ticket by ID from its project's file.
|
||||
func (s *JSONStore) Delete(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
projects, err := os.ReadDir(filepath.Join(s.dataDir, "projects"))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("ticket %s: not found", id)
|
||||
}
|
||||
return fmt.Errorf("list projects: %w", err)
|
||||
}
|
||||
|
||||
for _, p := range projects {
|
||||
if !p.IsDir() {
|
||||
continue
|
||||
}
|
||||
tickets, err := s.readUnlocked(p.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i, t := range tickets {
|
||||
if t.ID == id {
|
||||
tickets = append(tickets[:i], tickets[i+1:]...)
|
||||
return s.writeUnlocked(p.Name(), tickets)
|
||||
}
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("ticket %s: not found", id)
|
||||
}
|
||||
|
||||
func (s *JSONStore) readUnlocked(projectID string) ([]Ticket, error) {
|
||||
path := s.ticketsPath(projectID)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// Migrate from legacy tasks.json if present.
|
||||
legacy := s.legacyPath(projectID)
|
||||
data, err = os.ReadFile(legacy)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []Ticket{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read %s: %w", legacy, err)
|
||||
}
|
||||
path = legacy
|
||||
} else {
|
||||
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return []Ticket{}, nil
|
||||
}
|
||||
var tickets []Ticket
|
||||
if err := json.Unmarshal(data, &tickets); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
if tickets == nil {
|
||||
tickets = []Ticket{}
|
||||
}
|
||||
return tickets, nil
|
||||
}
|
||||
|
||||
func (s *JSONStore) writeUnlocked(projectID string, tickets []Ticket) error {
|
||||
path := s.ticketsPath(projectID)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create project dir: %w", err)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(tickets, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal tickets: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), "tickets-*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer func() { _ = os.Remove(tmpName) }()
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("write temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close temp file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("rename temp to %s: %w", path, err)
|
||||
}
|
||||
|
||||
// Clean up legacy file after successful write to tickets.json.
|
||||
_ = os.Remove(s.legacyPath(projectID))
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user