diff --git a/cmd/hf/main.go b/cmd/hf/main.go index 58e0997..7610a8f 100644 --- a/cmd/hf/main.go +++ b/cmd/hf/main.go @@ -190,6 +190,9 @@ func handleGroup(group help.Group, args []string) { case "proposal", "propose": handleProposalCommand(sub.Name, remaining) return + case "calendar": + handleCalendarCommand(sub.Name, remaining) + return case "comment": handleCommentCommand(sub.Name, remaining) return @@ -700,6 +703,45 @@ func handleSupportCommand(subCmd string, args []string) { } } +func handleCalendarCommand(subCmd string, args []string) { + tokenFlag := "" + var filtered []string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--token": + if i+1 < len(args) { + i++ + tokenFlag = args[i] + } + default: + filtered = append(filtered, args[i]) + } + } + + switch subCmd { + case "schedule": + commands.RunCalendarSchedule(filtered, tokenFlag) + case "show": + commands.RunCalendarShow(filtered, tokenFlag) + case "edit": + commands.RunCalendarEdit(filtered, tokenFlag) + case "cancel": + commands.RunCalendarCancel(filtered, tokenFlag) + case "date-list": + commands.RunCalendarDateList(filtered, tokenFlag) + case "plan-schedule": + commands.RunCalendarPlanSchedule(filtered, tokenFlag) + case "plan-list": + commands.RunCalendarPlanList(filtered, tokenFlag) + case "plan-edit": + commands.RunCalendarPlanEdit(filtered, tokenFlag) + case "plan-cancel": + commands.RunCalendarPlanCancel(filtered, tokenFlag) + default: + output.Errorf("hf calendar %s is not implemented yet", subCmd) + } +} + func handleProposalCommand(subCmd string, args []string) { tokenFlag := "" var filtered []string diff --git a/internal/commands/calendar.go b/internal/commands/calendar.go new file mode 100644 index 0000000..80c10cc --- /dev/null +++ b/internal/commands/calendar.go @@ -0,0 +1,660 @@ +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 [--job ] [--date ]`. +func RunCalendarSchedule(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + if len(args) < 3 { + output.Error("usage: hf calendar schedule [--job ] [--date ] [--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 ]`. +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 ] [flags]`. +func RunCalendarEdit(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + if len(args) < 1 { + output.Error("usage: hf calendar edit [--date ] [--slot-type ] [--estimated-duration ] [--scheduled-at ] [--job ]") + } + + 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 ] `. +func RunCalendarCancel(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + if len(args) < 1 { + output.Error("usage: hf calendar cancel [--date ]") + } + + 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 --at [--on-day ] [--on-week <1-4>] [--on-month ]`. +func RunCalendarPlanSchedule(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + if len(args) < 2 { + output.Error("usage: hf calendar plan-schedule --at [--on-day ] [--on-week <1-4>] [--on-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 [flags]`. +func RunCalendarPlanEdit(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + if len(args) < 1 { + output.Error("usage: hf calendar plan-edit [--at ] [--on-day ] [--on-week <1-4>] [--on-month ] [--slot-type ] [--estimated-duration ]") + } + + 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 `. +func RunCalendarPlanCancel(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + if len(args) < 1 { + output.Error("usage: hf calendar plan-cancel ") + } + + 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) +} diff --git a/internal/help/leaf.go b/internal/help/leaf.go index 42b9b5b..9a771c8 100644 --- a/internal/help/leaf.go +++ b/internal/help/leaf.go @@ -170,6 +170,15 @@ func leafHelpSpec(group, cmd string) (leafHelp, bool) { "proposal/essential/create": {Summary: "Create an essential", Usage: []string{"hf proposal essential create --proposal --title --type <feature|improvement|refactor> [--desc <desc>]"}, Flags: authFlagHelp()}, "proposal/essential/update": {Summary: "Update an essential", Usage: []string{"hf proposal essential update <essential-code> --proposal <proposal-code> [--title <title>] [--type <type>] [--desc <desc>]"}, Flags: authFlagHelp()}, "proposal/essential/delete": {Summary: "Delete an essential", Usage: []string{"hf proposal essential delete <essential-code> --proposal <proposal-code>"}, Flags: authFlagHelp()}, + "calendar/schedule": {Summary: "Create a one-off calendar slot", Usage: []string{"hf calendar schedule <slot-type> <scheduled-at> <estimated-duration> [--job <code>] [--date <yyyy-mm-dd>] [--priority <0-99>]"}, Flags: authFlagHelp()}, + "calendar/show": {Summary: "Show slots for a day", Usage: []string{"hf calendar show [--date <yyyy-mm-dd>]", "hf calendar show --json [--date <yyyy-mm-dd>]"}, Flags: authFlagHelp()}, + "calendar/edit": {Summary: "Edit a calendar slot", Usage: []string{"hf calendar edit <slot-id> [--date <yyyy-mm-dd>] [--slot-type <type>] [--estimated-duration <mins>] [--job <code>] [--scheduled-at <HH:mm>]"}, Flags: authFlagHelp(), Notes: []string{"For virtual plan slots, pass the virtual id like plan-12-2026-04-01."}}, + "calendar/cancel": {Summary: "Cancel a calendar slot", Usage: []string{"hf calendar cancel <slot-id> [--date <yyyy-mm-dd>]"}, Flags: authFlagHelp()}, + "calendar/date-list": {Summary: "List future dates with materialized slots", Usage: []string{"hf calendar date-list", "hf calendar date-list --json"}, Flags: authFlagHelp()}, + "calendar/plan-schedule": {Summary: "Create a recurring schedule plan", Usage: []string{"hf calendar plan-schedule <slot-type> <estimated-duration> --at <HH:mm> [--on-day <day>] [--on-week <1-4>] [--on-month <month>]"}, Flags: authFlagHelp()}, + "calendar/plan-list": {Summary: "List recurring schedule plans", Usage: []string{"hf calendar plan-list", "hf calendar plan-list --json"}, Flags: authFlagHelp()}, + "calendar/plan-edit": {Summary: "Edit a recurring schedule plan", Usage: []string{"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>]"}, Flags: authFlagHelp()}, + "calendar/plan-cancel": {Summary: "Cancel a recurring schedule plan", Usage: []string{"hf calendar plan-cancel <plan-id>"}, Flags: authFlagHelp()}, "comment/add": {Summary: "Add a comment to a task", Usage: []string{"hf comment add --task <task-code> --content <text>"}, Flags: authFlagHelp()}, "comment/list": {Summary: "List comments for a task", Usage: []string{"hf comment list --task <task-code>"}, Flags: authFlagHelp()}, "worklog/add": {Summary: "Add a work log entry", Usage: []string{"hf worklog add --task <task-code> --hours <n> [--desc <text>] [--date <yyyy-mm-dd>]"}, Flags: authFlagHelp()}, diff --git a/internal/help/surface.go b/internal/help/surface.go index 2bcc796..a8b565c 100644 --- a/internal/help/surface.go +++ b/internal/help/surface.go @@ -139,6 +139,21 @@ func CommandSurface() []Group { {Name: "essential", Description: "Manage proposal essentials", Permitted: has(perms, "task.create")}, }, }, + { + Name: "calendar", + Description: "Manage calendar slots and plans", + SubCommands: []Command{ + {Name: "schedule", Description: "Create a one-off slot", Permitted: has(perms, "task.create")}, + {Name: "show", Description: "Show slots for a day", Permitted: has(perms, "task.read")}, + {Name: "edit", Description: "Edit a slot", Permitted: has(perms, "task.write")}, + {Name: "cancel", Description: "Cancel a slot", Permitted: has(perms, "task.write")}, + {Name: "date-list", Description: "List dates with materialized slots", Permitted: has(perms, "task.read")}, + {Name: "plan-schedule", Description: "Create a recurring plan", Permitted: has(perms, "task.create")}, + {Name: "plan-list", Description: "List plans", Permitted: has(perms, "task.read")}, + {Name: "plan-edit", Description: "Edit a plan", Permitted: has(perms, "task.write")}, + {Name: "plan-cancel", Description: "Cancel a plan", Permitted: has(perms, "task.write")}, + }, + }, { Name: "comment", Description: "Manage task comments",