- Add hf calendar command group to command surface and router - Implement schedule/show/edit/cancel/date-list commands - Implement plan-schedule/plan-list/plan-edit/plan-cancel commands - Add leaf help for all calendar commands - Align CLI with backend calendar routes and response envelopes - Support virtual slot ids for edit/cancel - Validate with go build and go test ./...
661 lines
16 KiB
Go
661 lines
16 KiB
Go
package commands
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/client"
|
|
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/config"
|
|
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/output"
|
|
)
|
|
|
|
// --- Slot Commands ---
|
|
|
|
// RunCalendarSchedule implements `hf calendar schedule <slot-type> <scheduled-at> <estimated-duration> [--job <code>] [--date <yyyy-mm-dd>]`.
|
|
func RunCalendarSchedule(args []string, tokenFlag string) {
|
|
token := ResolveToken(tokenFlag)
|
|
|
|
if len(args) < 3 {
|
|
output.Error("usage: hf calendar schedule <slot-type> <scheduled-at> <estimated-duration> [--job <code>] [--date <yyyy-mm-dd>] [--priority <0-99>]")
|
|
}
|
|
|
|
slotType := args[0]
|
|
scheduledAt := args[1]
|
|
estimatedDuration := args[2]
|
|
|
|
date, jobCode, priority := "", "", ""
|
|
remaining := args[3:]
|
|
for i := 0; i < len(remaining); i++ {
|
|
switch remaining[i] {
|
|
case "--date":
|
|
if i+1 >= len(remaining) {
|
|
output.Error("--date requires a value")
|
|
}
|
|
i++
|
|
date = remaining[i]
|
|
case "--job":
|
|
if i+1 >= len(remaining) {
|
|
output.Error("--job requires a value")
|
|
}
|
|
i++
|
|
jobCode = remaining[i]
|
|
case "--priority":
|
|
if i+1 >= len(remaining) {
|
|
output.Error("--priority requires a value")
|
|
}
|
|
i++
|
|
priority = remaining[i]
|
|
default:
|
|
output.Errorf("unknown flag: %s", remaining[i])
|
|
}
|
|
}
|
|
|
|
payload := map[string]interface{}{
|
|
"slot_type": slotType,
|
|
"scheduled_at": scheduledAt,
|
|
"estimated_duration": estimatedDuration,
|
|
}
|
|
if date != "" {
|
|
payload["date"] = date
|
|
}
|
|
if priority != "" {
|
|
payload["priority"] = priority
|
|
}
|
|
if jobCode != "" {
|
|
payload["event_type"] = "job"
|
|
payload["event_data"] = map[string]interface{}{"type": "Task", "code": jobCode}
|
|
}
|
|
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
output.Errorf("cannot marshal payload: %v", err)
|
|
}
|
|
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
output.Errorf("config error: %v", err)
|
|
}
|
|
c := client.New(cfg.BaseURL, token)
|
|
data, err := c.Post("/calendar/slots", bytes.NewReader(body))
|
|
if err != nil {
|
|
output.Errorf("failed to schedule slot: %v", err)
|
|
}
|
|
|
|
if output.JSONMode {
|
|
var raw json.RawMessage
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
output.Errorf("invalid JSON response: %v", err)
|
|
}
|
|
output.PrintJSON(raw)
|
|
return
|
|
}
|
|
|
|
// Check for warnings
|
|
var resp map[string]interface{}
|
|
if err := json.Unmarshal(data, &resp); err == nil {
|
|
if ws, ok := resp["warnings"]; ok {
|
|
if warnings, ok := ws.([]interface{}); ok && len(warnings) > 0 {
|
|
fmt.Println("⚠️ Workload warnings:")
|
|
for _, w := range warnings {
|
|
if wm, ok := w.(map[string]interface{}); ok {
|
|
if msg, ok := wm["message"].(string); ok {
|
|
fmt.Printf(" - %s\n", msg)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fmt.Printf("slot scheduled: %s at %s (%s min)\n", slotType, scheduledAt, estimatedDuration)
|
|
}
|
|
|
|
// RunCalendarShow implements `hf calendar show [--date <yyyy-mm-dd>]`.
|
|
func RunCalendarShow(args []string, tokenFlag string) {
|
|
token := ResolveToken(tokenFlag)
|
|
|
|
date := ""
|
|
for i := 0; i < len(args); i++ {
|
|
switch args[i] {
|
|
case "--date":
|
|
if i+1 >= len(args) {
|
|
output.Error("--date requires a value")
|
|
}
|
|
i++
|
|
date = args[i]
|
|
default:
|
|
output.Errorf("unknown flag: %s", args[i])
|
|
}
|
|
}
|
|
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
output.Errorf("config error: %v", err)
|
|
}
|
|
c := client.New(cfg.BaseURL, token)
|
|
|
|
path := "/calendar/day"
|
|
if date != "" {
|
|
path += "?date=" + date
|
|
}
|
|
|
|
data, err := c.Get(path)
|
|
if err != nil {
|
|
output.Errorf("failed to show calendar: %v", err)
|
|
}
|
|
|
|
if output.JSONMode {
|
|
var raw json.RawMessage
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
output.Errorf("invalid JSON response: %v", err)
|
|
}
|
|
output.PrintJSON(raw)
|
|
return
|
|
}
|
|
|
|
var resp struct {
|
|
Slots []map[string]interface{} `json:"slots"`
|
|
}
|
|
if err := json.Unmarshal(data, &resp); err != nil {
|
|
output.Errorf("cannot parse calendar: %v", err)
|
|
}
|
|
slots := resp.Slots
|
|
|
|
if len(slots) == 0 {
|
|
if date != "" {
|
|
fmt.Printf("No slots for %s\n", date)
|
|
} else {
|
|
fmt.Println("No slots for today")
|
|
}
|
|
return
|
|
}
|
|
|
|
headers := []string{"ID", "TIME", "TYPE", "DURATION", "PRIORITY", "STATUS", "EVENT"}
|
|
var rows [][]string
|
|
for _, s := range slots {
|
|
slotID := fmt.Sprintf("%v", s["slot_id"])
|
|
scheduled := fmt.Sprintf("%v", s["scheduled_at"])
|
|
slotType := fmt.Sprintf("%v", s["slot_type"])
|
|
dur := fmt.Sprintf("%v min", s["estimated_duration"])
|
|
pri := fmt.Sprintf("%v", s["priority"])
|
|
status := fmt.Sprintf("%v", s["status"])
|
|
event := ""
|
|
if et, ok := s["event_type"]; ok && et != nil {
|
|
event = fmt.Sprintf("%v", et)
|
|
}
|
|
if ed, ok := s["event_data"]; ok && ed != nil {
|
|
if edm, ok := ed.(map[string]interface{}); ok {
|
|
if code, ok := edm["code"]; ok {
|
|
event += " " + fmt.Sprintf("%v", code)
|
|
}
|
|
if ev, ok := edm["event"]; ok {
|
|
event += " " + fmt.Sprintf("%v", ev)
|
|
}
|
|
}
|
|
}
|
|
if isVirt, ok := s["is_virtual"]; ok && isVirt == true {
|
|
slotID += " (plan)"
|
|
}
|
|
rows = append(rows, []string{slotID, scheduled, slotType, dur, pri, status, strings.TrimSpace(event)})
|
|
}
|
|
output.PrintTable(headers, rows)
|
|
}
|
|
|
|
// RunCalendarEdit implements `hf calendar edit [--date <yyyy-mm-dd>] <slot-id> [flags]`.
|
|
func RunCalendarEdit(args []string, tokenFlag string) {
|
|
token := ResolveToken(tokenFlag)
|
|
|
|
if len(args) < 1 {
|
|
output.Error("usage: hf calendar edit <slot-id> [--date <yyyy-mm-dd>] [--slot-type <type>] [--estimated-duration <mins>] [--scheduled-at <HH:mm>] [--job <code>]")
|
|
}
|
|
|
|
slotID := args[0]
|
|
date, slotType, duration, scheduledAt, jobCode := "", "", "", "", ""
|
|
|
|
for i := 1; i < len(args); i++ {
|
|
switch args[i] {
|
|
case "--date":
|
|
if i+1 >= len(args) {
|
|
output.Error("--date requires a value")
|
|
}
|
|
i++
|
|
date = args[i]
|
|
case "--slot-type":
|
|
if i+1 >= len(args) {
|
|
output.Error("--slot-type requires a value")
|
|
}
|
|
i++
|
|
slotType = args[i]
|
|
case "--estimated-duration":
|
|
if i+1 >= len(args) {
|
|
output.Error("--estimated-duration requires a value")
|
|
}
|
|
i++
|
|
duration = args[i]
|
|
case "--scheduled-at":
|
|
if i+1 >= len(args) {
|
|
output.Error("--scheduled-at requires a value")
|
|
}
|
|
i++
|
|
scheduledAt = args[i]
|
|
case "--job":
|
|
if i+1 >= len(args) {
|
|
output.Error("--job requires a value")
|
|
}
|
|
i++
|
|
jobCode = args[i]
|
|
default:
|
|
output.Errorf("unknown flag: %s", args[i])
|
|
}
|
|
}
|
|
|
|
payload := make(map[string]interface{})
|
|
if slotType != "" {
|
|
payload["slot_type"] = slotType
|
|
}
|
|
if duration != "" {
|
|
payload["estimated_duration"] = duration
|
|
}
|
|
if scheduledAt != "" {
|
|
payload["scheduled_at"] = scheduledAt
|
|
}
|
|
if jobCode != "" {
|
|
payload["event_type"] = "job"
|
|
payload["event_data"] = map[string]interface{}{"type": "Task", "code": jobCode}
|
|
}
|
|
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
output.Errorf("cannot marshal payload: %v", err)
|
|
}
|
|
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
output.Errorf("config error: %v", err)
|
|
}
|
|
c := client.New(cfg.BaseURL, token)
|
|
path := "/calendar/slots/" + slotID
|
|
if strings.HasPrefix(slotID, "plan-") {
|
|
path = "/calendar/slots/virtual/" + slotID
|
|
}
|
|
_ = date // kept for CLI compatibility; backend identifies virtual slots via slot-id
|
|
data, err := c.Patch(path, bytes.NewReader(body))
|
|
if err != nil {
|
|
output.Errorf("failed to edit slot: %v", err)
|
|
}
|
|
|
|
if output.JSONMode {
|
|
var raw json.RawMessage
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
output.Errorf("invalid JSON response: %v", err)
|
|
}
|
|
output.PrintJSON(raw)
|
|
return
|
|
}
|
|
|
|
var resp map[string]interface{}
|
|
if err := json.Unmarshal(data, &resp); err == nil {
|
|
if ws, ok := resp["warnings"]; ok {
|
|
if warnings, ok := ws.([]interface{}); ok && len(warnings) > 0 {
|
|
fmt.Println("⚠️ Workload warnings:")
|
|
for _, w := range warnings {
|
|
if wm, ok := w.(map[string]interface{}); ok {
|
|
if msg, ok := wm["message"].(string); ok {
|
|
fmt.Printf(" - %s\n", msg)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fmt.Printf("slot edited: %s\n", slotID)
|
|
}
|
|
|
|
// RunCalendarCancel implements `hf calendar cancel [--date <yyyy-mm-dd>] <slot-id>`.
|
|
func RunCalendarCancel(args []string, tokenFlag string) {
|
|
token := ResolveToken(tokenFlag)
|
|
|
|
if len(args) < 1 {
|
|
output.Error("usage: hf calendar cancel <slot-id> [--date <yyyy-mm-dd>]")
|
|
}
|
|
|
|
slotID := args[0]
|
|
date := ""
|
|
for i := 1; i < len(args); i++ {
|
|
switch args[i] {
|
|
case "--date":
|
|
if i+1 >= len(args) {
|
|
output.Error("--date requires a value")
|
|
}
|
|
i++
|
|
date = args[i]
|
|
default:
|
|
output.Errorf("unknown flag: %s", args[i])
|
|
}
|
|
}
|
|
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
output.Errorf("config error: %v", err)
|
|
}
|
|
c := client.New(cfg.BaseURL, token)
|
|
path := "/calendar/slots/" + slotID + "/cancel"
|
|
if strings.HasPrefix(slotID, "plan-") {
|
|
path = "/calendar/slots/virtual/" + slotID + "/cancel"
|
|
}
|
|
_ = date // kept for CLI compatibility; backend identifies virtual slots via slot-id
|
|
_, err = c.Post(path, nil)
|
|
if err != nil {
|
|
output.Errorf("failed to cancel slot: %v", err)
|
|
}
|
|
|
|
fmt.Printf("slot cancelled: %s\n", slotID)
|
|
}
|
|
|
|
// RunCalendarDateList implements `hf calendar date-list`.
|
|
func RunCalendarDateList(args []string, tokenFlag string) {
|
|
token := ResolveToken(tokenFlag)
|
|
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
output.Errorf("config error: %v", err)
|
|
}
|
|
c := client.New(cfg.BaseURL, token)
|
|
data, err := c.Get("/calendar/dates")
|
|
if err != nil {
|
|
output.Errorf("failed to list dates: %v", err)
|
|
}
|
|
|
|
if output.JSONMode {
|
|
var raw json.RawMessage
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
output.Errorf("invalid JSON response: %v", err)
|
|
}
|
|
output.PrintJSON(raw)
|
|
return
|
|
}
|
|
|
|
var resp struct {
|
|
Dates []string `json:"dates"`
|
|
}
|
|
if err := json.Unmarshal(data, &resp); err != nil {
|
|
output.Errorf("cannot parse dates: %v", err)
|
|
}
|
|
dates := resp.Dates
|
|
|
|
if len(dates) == 0 {
|
|
fmt.Println("No future dates with materialized slots")
|
|
return
|
|
}
|
|
|
|
for _, d := range dates {
|
|
fmt.Println(d)
|
|
}
|
|
}
|
|
|
|
// --- Plan Commands ---
|
|
|
|
// RunCalendarPlanSchedule implements `hf calendar plan-schedule <slot-type> <estimated-duration> --at <HH:mm> [--on-day <day>] [--on-week <1-4>] [--on-month <month>]`.
|
|
func RunCalendarPlanSchedule(args []string, tokenFlag string) {
|
|
token := ResolveToken(tokenFlag)
|
|
|
|
if len(args) < 2 {
|
|
output.Error("usage: hf calendar plan-schedule <slot-type> <estimated-duration> --at <HH:mm> [--on-day <day>] [--on-week <1-4>] [--on-month <month>]")
|
|
}
|
|
|
|
slotType := args[0]
|
|
duration := args[1]
|
|
atTime, onDay, onWeek, onMonth := "", "", "", ""
|
|
|
|
for i := 2; i < len(args); i++ {
|
|
switch args[i] {
|
|
case "--at":
|
|
if i+1 >= len(args) {
|
|
output.Error("--at requires a value")
|
|
}
|
|
i++
|
|
atTime = args[i]
|
|
case "--on-day":
|
|
if i+1 >= len(args) {
|
|
output.Error("--on-day requires a value")
|
|
}
|
|
i++
|
|
onDay = args[i]
|
|
case "--on-week":
|
|
if i+1 >= len(args) {
|
|
output.Error("--on-week requires a value")
|
|
}
|
|
i++
|
|
onWeek = args[i]
|
|
case "--on-month":
|
|
if i+1 >= len(args) {
|
|
output.Error("--on-month requires a value")
|
|
}
|
|
i++
|
|
onMonth = args[i]
|
|
default:
|
|
output.Errorf("unknown flag: %s", args[i])
|
|
}
|
|
}
|
|
|
|
if atTime == "" {
|
|
output.Error("--at is required")
|
|
}
|
|
|
|
payload := map[string]interface{}{
|
|
"slot_type": slotType,
|
|
"estimated_duration": duration,
|
|
"at_time": atTime,
|
|
}
|
|
if onDay != "" {
|
|
payload["on_day"] = onDay
|
|
}
|
|
if onWeek != "" {
|
|
payload["on_week"] = onWeek
|
|
}
|
|
if onMonth != "" {
|
|
payload["on_month"] = onMonth
|
|
}
|
|
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
output.Errorf("cannot marshal payload: %v", err)
|
|
}
|
|
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
output.Errorf("config error: %v", err)
|
|
}
|
|
c := client.New(cfg.BaseURL, token)
|
|
data, err := c.Post("/calendar/plans", bytes.NewReader(body))
|
|
if err != nil {
|
|
output.Errorf("failed to create plan: %v", err)
|
|
}
|
|
|
|
if output.JSONMode {
|
|
var raw json.RawMessage
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
output.Errorf("invalid JSON response: %v", err)
|
|
}
|
|
output.PrintJSON(raw)
|
|
return
|
|
}
|
|
|
|
fmt.Printf("plan created: %s at %s (%s min)\n", slotType, atTime, duration)
|
|
}
|
|
|
|
// RunCalendarPlanList implements `hf calendar plan-list`.
|
|
func RunCalendarPlanList(args []string, tokenFlag string) {
|
|
token := ResolveToken(tokenFlag)
|
|
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
output.Errorf("config error: %v", err)
|
|
}
|
|
c := client.New(cfg.BaseURL, token)
|
|
data, err := c.Get("/calendar/plans")
|
|
if err != nil {
|
|
output.Errorf("failed to list plans: %v", err)
|
|
}
|
|
|
|
if output.JSONMode {
|
|
var raw json.RawMessage
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
output.Errorf("invalid JSON response: %v", err)
|
|
}
|
|
output.PrintJSON(raw)
|
|
return
|
|
}
|
|
|
|
var resp struct {
|
|
Plans []map[string]interface{} `json:"plans"`
|
|
}
|
|
if err := json.Unmarshal(data, &resp); err != nil {
|
|
output.Errorf("cannot parse plans: %v", err)
|
|
}
|
|
plans := resp.Plans
|
|
|
|
if len(plans) == 0 {
|
|
fmt.Println("No schedule plans")
|
|
return
|
|
}
|
|
|
|
headers := []string{"ID", "TYPE", "AT", "ON DAY", "ON WEEK", "ON MONTH", "DURATION", "ACTIVE"}
|
|
var rows [][]string
|
|
for _, p := range plans {
|
|
id := fmt.Sprintf("%v", p["id"])
|
|
slotType := fmt.Sprintf("%v", p["slot_type"])
|
|
at := fmt.Sprintf("%v", p["at_time"])
|
|
onDay := ""
|
|
if d, ok := p["on_day"]; ok && d != nil {
|
|
onDay = fmt.Sprintf("%v", d)
|
|
}
|
|
onWeek := ""
|
|
if w, ok := p["on_week"]; ok && w != nil {
|
|
onWeek = fmt.Sprintf("%v", w)
|
|
}
|
|
onMonth := ""
|
|
if m, ok := p["on_month"]; ok && m != nil {
|
|
onMonth = fmt.Sprintf("%v", m)
|
|
}
|
|
dur := fmt.Sprintf("%v min", p["estimated_duration"])
|
|
active := "yes"
|
|
if a, ok := p["is_active"]; ok && a == false {
|
|
active = "no"
|
|
}
|
|
rows = append(rows, []string{id, slotType, at, onDay, onWeek, onMonth, dur, active})
|
|
}
|
|
output.PrintTable(headers, rows)
|
|
}
|
|
|
|
// RunCalendarPlanEdit implements `hf calendar plan-edit <plan-id> [flags]`.
|
|
func RunCalendarPlanEdit(args []string, tokenFlag string) {
|
|
token := ResolveToken(tokenFlag)
|
|
|
|
if len(args) < 1 {
|
|
output.Error("usage: hf calendar plan-edit <plan-id> [--at <HH:mm>] [--on-day <day>] [--on-week <1-4>] [--on-month <month>] [--slot-type <type>] [--estimated-duration <mins>]")
|
|
}
|
|
|
|
planID := args[0]
|
|
payload := make(map[string]interface{})
|
|
|
|
for i := 1; i < len(args); i++ {
|
|
switch args[i] {
|
|
case "--at":
|
|
if i+1 >= len(args) {
|
|
output.Error("--at requires a value")
|
|
}
|
|
i++
|
|
payload["at_time"] = args[i]
|
|
case "--on-day":
|
|
if i+1 >= len(args) {
|
|
output.Error("--on-day requires a value")
|
|
}
|
|
i++
|
|
payload["on_day"] = args[i]
|
|
case "--on-week":
|
|
if i+1 >= len(args) {
|
|
output.Error("--on-week requires a value")
|
|
}
|
|
i++
|
|
payload["on_week"] = args[i]
|
|
case "--on-month":
|
|
if i+1 >= len(args) {
|
|
output.Error("--on-month requires a value")
|
|
}
|
|
i++
|
|
payload["on_month"] = args[i]
|
|
case "--slot-type":
|
|
if i+1 >= len(args) {
|
|
output.Error("--slot-type requires a value")
|
|
}
|
|
i++
|
|
payload["slot_type"] = args[i]
|
|
case "--estimated-duration":
|
|
if i+1 >= len(args) {
|
|
output.Error("--estimated-duration requires a value")
|
|
}
|
|
i++
|
|
payload["estimated_duration"] = args[i]
|
|
default:
|
|
output.Errorf("unknown flag: %s", args[i])
|
|
}
|
|
}
|
|
|
|
if len(payload) == 0 {
|
|
output.Error("nothing to edit — provide at least one flag")
|
|
}
|
|
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
output.Errorf("cannot marshal payload: %v", err)
|
|
}
|
|
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
output.Errorf("config error: %v", err)
|
|
}
|
|
c := client.New(cfg.BaseURL, token)
|
|
data, err := c.Patch("/calendar/plans/"+planID, bytes.NewReader(body))
|
|
if err != nil {
|
|
output.Errorf("failed to edit plan: %v", err)
|
|
}
|
|
|
|
if output.JSONMode {
|
|
var raw json.RawMessage
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
output.Errorf("invalid JSON response: %v", err)
|
|
}
|
|
output.PrintJSON(raw)
|
|
return
|
|
}
|
|
|
|
fmt.Printf("plan edited: %s\n", planID)
|
|
}
|
|
|
|
// RunCalendarPlanCancel implements `hf calendar plan-cancel <plan-id>`.
|
|
func RunCalendarPlanCancel(args []string, tokenFlag string) {
|
|
token := ResolveToken(tokenFlag)
|
|
|
|
if len(args) < 1 {
|
|
output.Error("usage: hf calendar plan-cancel <plan-id>")
|
|
}
|
|
|
|
planID := args[0]
|
|
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
output.Errorf("config error: %v", err)
|
|
}
|
|
c := client.New(cfg.BaseURL, token)
|
|
_, err = c.Post("/calendar/plans/"+planID+"/cancel", nil)
|
|
if err != nil {
|
|
output.Errorf("failed to cancel plan: %v", err)
|
|
}
|
|
|
|
fmt.Printf("plan cancelled: %s\n", planID)
|
|
}
|