Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,9 @@ dida365 project data <project-id>
dida365 task create \
--title "Deploy to production" \
--project-id "proj123" \
--content "Deploy v2.0.0"
--content "Deploy v2.0.0" \
--due-date "2026-04-30" \
--start-date "2026-05-01"
```

**List tasks in a project:**
Expand All @@ -122,7 +124,9 @@ dida365 task get task456 --project-id proj123
dida365 task update task456 \
--project-id proj123 \
--title "New title" \
--content "Updated content"
--content "Updated content" \
--due-date "2026-05-01 18:30" \
--start-date "2026-05-01 18:30"
```

**Complete a task:**
Expand All @@ -144,6 +148,25 @@ dida365 project columns <project-id>
dida365 task move task456 --project-id proj123 --column-id <column-id>
```

**Accepted `--start-date` and `--due-date` formats:**
```bash
# All-day (date only)
dida365 task create --title "Monthly summary" --project-id "proj123" \
--start-date "2026-04-28" --due-date "2026-04-30"

# Specific local time (space separator)
dida365 task create --title "Release window" --project-id "proj123" \
--start-date "2026-04-30 10:00" --due-date "2026-04-30 18:30"

# Specific local time (T separator)
dida365 task create --title "Release window" --project-id "proj123" \
--start-date "2026-04-30T10:00" --due-date "2026-04-30T18:30"

# RFC3339 with timezone
dida365 task update task456 --project-id proj123 \
--start-date "2026-04-30T09:00:00+08:00" --due-date "2026-04-30T23:59:59+08:00"
```

## Scripting Examples

### Extract specific fields with jq
Expand Down Expand Up @@ -217,6 +240,8 @@ Commands output the resource or array directly:
"id": "task123",
"projectId": "proj456",
"title": "Task title",
"dueDate": "2026-04-30T15:59:59Z",
"isAllDay": false,
"status": 0,
"sortOrder": 1
}
Expand Down
68 changes: 64 additions & 4 deletions cmd/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ var (
taskTitle string
taskProjectID string
taskContent string
taskDesc string
taskColumnID string
taskStartDate string
taskDueDate string
taskShowDesc bool
)

var taskCmd = &cobra.Command{
Expand Down Expand Up @@ -99,26 +103,32 @@ func init() {
taskCreateCmd.Flags().StringVar(&taskTitle, "title", "", "Task title (required)")
taskCreateCmd.Flags().StringVar(&taskProjectID, "project-id", "", "Project ID (required)")
taskCreateCmd.Flags().StringVar(&taskContent, "content", "", "Task content (optional)")
taskCreateCmd.Flags().StringVar(&taskDesc, "desc", "", "Task description / checklist description (optional)")
taskCreateCmd.Flags().StringVar(&taskStartDate, "start-date", "", "Task start date, accepts YYYY-MM-DD, YYYY-MM-DD HH:MM, YYYY-MM-DDTHH:MM, or RFC3339")
taskCreateCmd.Flags().StringVar(&taskDueDate, "due-date", "", "Task deadline, accepts YYYY-MM-DD, YYYY-MM-DD HH:MM, YYYY-MM-DDTHH:MM, or RFC3339")
taskCreateCmd.MarkFlagRequired("title")
taskCreateCmd.MarkFlagRequired("project-id")

// Flags for get command
taskGetCmd.Flags().StringVar(&taskProjectID, "project-id", "", "Project ID (required)")
taskGetCmd.Flags().BoolVar(&taskShowDesc, "show-desc", false, "Show task description field")
taskGetCmd.MarkFlagRequired("project-id")

// Flags for update command
taskUpdateCmd.Flags().StringVar(&taskTitle, "title", "", "Task title (optional)")
taskUpdateCmd.Flags().StringVar(&taskContent, "content", "", "Task content (optional)")
taskUpdateCmd.Flags().StringVar(&taskDesc, "desc", "", "Task description / checklist description (optional)")
taskUpdateCmd.Flags().StringVar(&taskStartDate, "start-date", "", "Task start date, accepts YYYY-MM-DD, YYYY-MM-DD HH:MM, YYYY-MM-DDTHH:MM, or RFC3339")
taskUpdateCmd.Flags().StringVar(&taskDueDate, "due-date", "", "Task deadline, accepts YYYY-MM-DD, YYYY-MM-DD HH:MM, YYYY-MM-DDTHH:MM, or RFC3339")
taskUpdateCmd.Flags().StringVar(&taskProjectID, "project-id", "", "Project ID (required)")
taskUpdateCmd.MarkFlagRequired("project-id")

taskDeleteCmd.Flags().StringVar(&taskProjectID, "project-id", "", "Project ID (required)")
taskDeleteCmd.MarkFlagRequired("project-id")

// Flags for complete command
taskCompleteCmd.Flags().StringVar(&taskProjectID, "project-id", "", "Project ID (required)")
taskCompleteCmd.MarkFlagRequired("project-id")

// Flags for delete command
taskDeleteCmd.Flags().StringVar(&taskProjectID, "project-id", "", "Project ID (required)")
taskDeleteCmd.MarkFlagRequired("project-id")
}

func runTaskCreate(cmd *cobra.Command, args []string) error {
Expand All @@ -128,6 +138,26 @@ func runTaskCreate(cmd *cobra.Command, args []string) error {
Title: taskTitle,
ProjectID: taskProjectID,
Content: taskContent,
Desc: taskDesc,
}

if cmd.Flags().Changed("start-date") {
normalizedStartDate, _, err := normalizeDateInput(taskStartDate)
if err != nil {
outputError(err, "VALIDATION_ERROR", 5)
return nil
}
taskCreate.StartDate = normalizedStartDate
}

if cmd.Flags().Changed("due-date") {
normalizedDueDate, isAllDay, err := normalizeDateInput(taskDueDate)
if err != nil {
outputError(err, "VALIDATION_ERROR", 5)
return nil
}
taskCreate.DueDate = normalizedDueDate
taskCreate.IsAllDay = &isAllDay
}

task, err := c.CreateTask(taskCreate)
Expand All @@ -150,6 +180,10 @@ func runTaskGet(cmd *cobra.Command, args []string) error {
return nil
}

if !taskShowDesc {
task.Desc = ""
}

outputJSON(task)
return nil
}
Expand Down Expand Up @@ -186,6 +220,32 @@ func runTaskUpdate(cmd *cobra.Command, args []string) error {
hasChanges = true
}

if cmd.Flags().Changed("desc") {
updates.Desc = &taskDesc
hasChanges = true
}

if cmd.Flags().Changed("start-date") {
normalizedStartDate, _, err := normalizeDateInput(taskStartDate)
if err != nil {
outputError(err, "VALIDATION_ERROR", 5)
return nil
}
updates.StartDate = &normalizedStartDate
hasChanges = true
}

if cmd.Flags().Changed("due-date") {
normalizedDueDate, isAllDay, err := normalizeDateInput(taskDueDate)
if err != nil {
outputError(err, "VALIDATION_ERROR", 5)
return nil
}
updates.DueDate = &normalizedDueDate
updates.IsAllDay = &isAllDay
hasChanges = true
}

if !hasChanges {
outputError(fmt.Errorf("no fields to update"), "VALIDATION_ERROR", 5)
return nil
Expand Down
48 changes: 48 additions & 0 deletions cmd/task_date.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package cmd

import (
"fmt"
"strings"
"time"
)

const apiDateTimeLayout = "2006-01-02T15:04:05-0700"

var dueDateLayoutsWithZone = []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02T15:04:05.999999999-0700",
"2006-01-02T15:04:05-0700",
}

var dueDateLayoutsLocal = []string{
"2006-01-02 15:04",
"2006-01-02T15:04",
"2006-01-02 15:04:05",
"2006-01-02T15:04:05",
}

func normalizeDateInput(input string) (string, bool, error) {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
return "", false, fmt.Errorf("due date cannot be empty")
}

if t, err := time.ParseInLocation("2006-01-02", trimmed, time.Local); err == nil {
return t.Format(apiDateTimeLayout), true, nil
}

for _, layout := range dueDateLayoutsLocal {
if t, err := time.ParseInLocation(layout, trimmed, time.Local); err == nil {
return t.Format(apiDateTimeLayout), false, nil
}
}

for _, layout := range dueDateLayoutsWithZone {
if t, err := time.Parse(layout, trimmed); err == nil {
return t.Format(apiDateTimeLayout), false, nil
}
}

return "", false, fmt.Errorf("unsupported due date format %q; use YYYY-MM-DD, YYYY-MM-DD HH:MM, YYYY-MM-DDTHH:MM, or RFC3339", input)
}
59 changes: 59 additions & 0 deletions cmd/task_date_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package cmd

import "testing"

func TestnormalizeDateInput(t *testing.T) {
tests := []struct {
name string
input string
want string
wantAllDay bool
wantErr bool
}{
{
name: "date only becomes all day",
input: "2026-04-30",
want: "2026-04-30T00:00:00+0800",
wantAllDay: true,
},
{
name: "local datetime with space",
input: "2026-04-30 18:30",
want: "2026-04-30T18:30:00+0800",
wantAllDay: false,
},
{
name: "rfc3339 with timezone",
input: "2026-04-30T23:59:59+08:00",
want: "2026-04-30T23:59:59+0800",
wantAllDay: false,
},
{
name: "reject unsupported format",
input: "this saturday",
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, gotAllDay, err := normalizeDateInput(tt.input)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}

if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Fatalf("normalizeDateInput() = %q, want %q", got, tt.want)
}
if gotAllDay != tt.wantAllDay {
t.Fatalf("normalizeDateInput() allDay = %v, want %v", gotAllDay, tt.wantAllDay)
}
})
}
}
Loading