Add forms question management and watch commands

New commands: add-question, delete-question, move-question, update, and
watch create/list/delete/renew. Covers the full Google Forms API surface
for form modification via batchUpdate and response watches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alex Hillman 2026-02-15 13:11:46 -05:00 committed by Peter Steinberger
parent 4a69ebbfa5
commit 1de23055b8
3 changed files with 682 additions and 3 deletions

View File

@ -16,9 +16,14 @@ import (
var newFormsService = googleapi.NewForms
type FormsCmd struct {
Get FormsGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get a form"`
Create FormsCreateCmd `cmd:"" name:"create" aliases:"new" help:"Create a form"`
Responses FormsResponsesCmd `cmd:"" name:"responses" help:"Form responses"`
Get FormsGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get a form"`
Create FormsCreateCmd `cmd:"" name:"create" aliases:"new" help:"Create a form"`
Update FormsUpdateCmd `cmd:"" name:"update" aliases:"edit" help:"Update form title, description, or settings"`
AddQuestion FormsAddQuestionCmd `cmd:"" name:"add-question" aliases:"add-q,aq" help:"Add a question to a form"`
DeleteQuestion FormsDeleteQuestionCmd `cmd:"" name:"delete-question" aliases:"delete-q,dq,rm-q" help:"Delete a question by index"`
MoveQuestion FormsMoveQuestionCmd `cmd:"" name:"move-question" aliases:"move-q,mq" help:"Move a question to a new position"`
Responses FormsResponsesCmd `cmd:"" name:"responses" help:"Form responses"`
Watch FormsWatchCmd `cmd:"" name:"watch" aliases:"watches" help:"Response watches (push notifications)"`
}
type FormsResponsesCmd struct {
@ -111,6 +116,13 @@ func (c *FormsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
u.Out().Printf("created\ttrue")
printFormSummary(u, form, formID)
u.Err().Println("")
u.Err().Println("# Tip: Email notifications for new responses must be enabled manually:")
u.Err().Println("# 1. Open the edit URL above in your browser")
u.Err().Println("# 2. Click the Responses tab")
u.Err().Println("# 3. Click the three-dot menu (⋮)")
u.Err().Println("# 4. Toggle 'Get email notifications for new responses'")
u.Err().Println("# This setting is not available via the API.")
return nil
}

View File

@ -0,0 +1,426 @@
package cmd
import (
"context"
"os"
"strconv"
"strings"
formsapi "google.golang.org/api/forms/v1"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
// FormsAddQuestionCmd adds a question to an existing form via batchUpdate.
type FormsAddQuestionCmd struct {
FormID string `arg:"" name:"formId" help:"Form ID"`
Title string `name:"title" help:"Question title/text" required:""`
Type string `name:"type" help:"Question type: text|paragraph|radio|checkbox|dropdown|scale|date|time" default:"text"`
Required bool `name:"required" help:"Whether an answer is required"`
Options []string `name:"option" help:"Choice options (for radio/checkbox/dropdown, repeat for each)" short:"o"`
Index int `name:"index" help:"Position to insert (0-based, default append)" default:"-1"`
// Scale-specific
ScaleLow int `name:"scale-low" help:"Scale minimum value" default:"1"`
ScaleHigh int `name:"scale-high" help:"Scale maximum value" default:"5"`
ScaleLowLabel string `name:"scale-low-label" help:"Label for low end of scale"`
ScaleHighLabel string `name:"scale-high-label" help:"Label for high end of scale"`
// Date/time specific
IncludeTime bool `name:"include-time" help:"Include time picker (for date type)"`
IncludeYear bool `name:"include-year" help:"Include year field (for date type)"`
Duration bool `name:"duration" help:"Ask for duration instead of time (for time type)"`
Description string `name:"description" help:"Question description/help text"`
}
func (c *FormsAddQuestionCmd) Run(ctx context.Context, flags *RootFlags) error {
account, err := requireAccount(flags)
if err != nil {
return err
}
formID := strings.TrimSpace(normalizeGoogleID(c.FormID))
if formID == "" {
return usage("empty formId")
}
title := strings.TrimSpace(c.Title)
if title == "" {
return usage("empty --title")
}
qType := strings.ToLower(strings.TrimSpace(c.Type))
question, err := buildQuestion(qType, c)
if err != nil {
return err
}
if dryRunErr := dryRunExit(ctx, flags, "forms.addQuestion", map[string]any{
"form_id": formID,
"title": title,
"type": qType,
"required": c.Required,
"options": c.Options,
"index": c.Index,
"description": c.Description,
}); dryRunErr != nil {
return dryRunErr
}
svc, err := newFormsService(ctx, account)
if err != nil {
return err
}
item := &formsapi.Item{
Title: title,
Description: strings.TrimSpace(c.Description),
QuestionItem: &formsapi.QuestionItem{
Question: question,
},
}
createReq := &formsapi.CreateItemRequest{
Item: item,
}
// Determine the insertion index. The API requires a Location.
// For index 0 we must use ForceSendFields since 0 is Go's zero-value.
var insertAt int64
if c.Index >= 0 {
insertAt = int64(c.Index)
} else {
// Append: get the form to find current item count.
currentForm, getErr := svc.Forms.Get(formID).Context(ctx).Do()
if getErr != nil {
return getErr
}
insertAt = int64(len(currentForm.Items))
}
createReq.Location = &formsapi.Location{
Index: insertAt,
ForceSendFields: []string{"Index"},
}
batchReq := &formsapi.BatchUpdateFormRequest{
Requests: []*formsapi.Request{
{CreateItem: createReq},
},
IncludeFormInResponse: true,
}
resp, err := svc.Forms.BatchUpdate(formID, batchReq).Context(ctx).Do()
if err != nil {
return err
}
// Determine the actual index used for output.
var insertIndex int64 = -1
if createReq.Location != nil {
insertIndex = createReq.Location.Index
} else if resp.Form != nil {
// Appended — index is last item position.
insertIndex = int64(len(resp.Form.Items) - 1)
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"created": true,
"form_id": formID,
"title": title,
"type": qType,
"index": insertIndex,
"form": resp.Form,
"edit_url": formEditURL(formID),
})
}
u := ui.FromContext(ctx)
u.Out().Printf("created\ttrue")
u.Out().Printf("form_id\t%s", formID)
u.Out().Printf("question\t%s", title)
u.Out().Printf("type\t%s", qType)
u.Out().Printf("index\t%d", insertIndex)
u.Out().Printf("edit_url\t%s", formEditURL(formID))
return nil
}
func buildQuestion(qType string, c *FormsAddQuestionCmd) (*formsapi.Question, error) {
q := &formsapi.Question{
Required: c.Required,
}
switch qType {
case "text":
q.TextQuestion = &formsapi.TextQuestion{Paragraph: false}
case "paragraph":
q.TextQuestion = &formsapi.TextQuestion{Paragraph: true}
case "radio", "checkbox", "dropdown":
if len(c.Options) == 0 {
return nil, usage("--option is required for " + qType + " questions (repeat for each choice)")
}
apiType := map[string]string{
"radio": "RADIO",
"checkbox": "CHECKBOX",
"dropdown": "DROP_DOWN",
}[qType]
opts := make([]*formsapi.Option, len(c.Options))
for i, v := range c.Options {
opts[i] = &formsapi.Option{Value: v}
}
q.ChoiceQuestion = &formsapi.ChoiceQuestion{
Type: apiType,
Options: opts,
}
case "scale":
q.ScaleQuestion = &formsapi.ScaleQuestion{
Low: int64(c.ScaleLow),
High: int64(c.ScaleHigh),
LowLabel: c.ScaleLowLabel,
HighLabel: c.ScaleHighLabel,
}
case "date":
q.DateQuestion = &formsapi.DateQuestion{
IncludeTime: c.IncludeTime,
IncludeYear: c.IncludeYear,
}
case "time":
q.TimeQuestion = &formsapi.TimeQuestion{
Duration: c.Duration,
}
default:
return nil, usage("unknown question type: " + qType + " (use text|paragraph|radio|checkbox|dropdown|scale|date|time)")
}
return q, nil
}
// FormsDeleteQuestionCmd removes a question from a form by index.
type FormsDeleteQuestionCmd struct {
FormID string `arg:"" name:"formId" help:"Form ID"`
Index int `arg:"" name:"index" help:"Question index (0-based)"`
}
func (c *FormsDeleteQuestionCmd) Run(ctx context.Context, flags *RootFlags) error {
account, err := requireAccount(flags)
if err != nil {
return err
}
formID := strings.TrimSpace(normalizeGoogleID(c.FormID))
if formID == "" {
return usage("empty formId")
}
if c.Index < 0 {
return usage("index must be >= 0")
}
if dryRunErr := dryRunExit(ctx, flags, "forms.deleteQuestion", map[string]any{
"form_id": formID,
"index": c.Index,
}); dryRunErr != nil {
return dryRunErr
}
svc, err := newFormsService(ctx, account)
if err != nil {
return err
}
batchReq := &formsapi.BatchUpdateFormRequest{
Requests: []*formsapi.Request{
{
DeleteItem: &formsapi.DeleteItemRequest{
Location: &formsapi.Location{Index: int64(c.Index)},
},
},
},
}
_, err = svc.Forms.BatchUpdate(formID, batchReq).Context(ctx).Do()
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"deleted": true,
"form_id": formID,
"index": c.Index,
})
}
u := ui.FromContext(ctx)
u.Out().Printf("deleted\ttrue")
u.Out().Printf("form_id\t%s", formID)
u.Out().Printf("index\t%d", c.Index)
return nil
}
// FormsMoveQuestionCmd moves a question to a new position.
type FormsMoveQuestionCmd struct {
FormID string `arg:"" name:"formId" help:"Form ID"`
OldIndex int `arg:"" name:"oldIndex" help:"Current question index (0-based)"`
NewIndex int `arg:"" name:"newIndex" help:"Target question index (0-based)"`
}
func (c *FormsMoveQuestionCmd) Run(ctx context.Context, flags *RootFlags) error {
account, err := requireAccount(flags)
if err != nil {
return err
}
formID := strings.TrimSpace(normalizeGoogleID(c.FormID))
if formID == "" {
return usage("empty formId")
}
if c.OldIndex < 0 || c.NewIndex < 0 {
return usage("indices must be >= 0")
}
if dryRunErr := dryRunExit(ctx, flags, "forms.moveQuestion", map[string]any{
"form_id": formID,
"old_index": c.OldIndex,
"new_index": c.NewIndex,
}); dryRunErr != nil {
return dryRunErr
}
svc, err := newFormsService(ctx, account)
if err != nil {
return err
}
batchReq := &formsapi.BatchUpdateFormRequest{
Requests: []*formsapi.Request{
{
MoveItem: &formsapi.MoveItemRequest{
OriginalLocation: &formsapi.Location{Index: int64(c.OldIndex)},
NewLocation: &formsapi.Location{Index: int64(c.NewIndex)},
},
},
},
}
_, err = svc.Forms.BatchUpdate(formID, batchReq).Context(ctx).Do()
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"moved": true,
"form_id": formID,
"old_index": c.OldIndex,
"new_index": c.NewIndex,
})
}
u := ui.FromContext(ctx)
u.Out().Printf("moved\ttrue")
u.Out().Printf("form_id\t%s", formID)
u.Out().Printf("old_index\t%d", c.OldIndex)
u.Out().Printf("new_index\t%d", c.NewIndex)
return nil
}
// FormsUpdateCmd modifies form title, description, or settings.
type FormsUpdateCmd struct {
FormID string `arg:"" name:"formId" help:"Form ID"`
Title string `name:"title" help:"New form title"`
Description string `name:"description" help:"New form description"`
IsQuiz string `name:"quiz" help:"Enable quiz mode (true/false)"`
}
func (c *FormsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error {
account, err := requireAccount(flags)
if err != nil {
return err
}
formID := strings.TrimSpace(normalizeGoogleID(c.FormID))
if formID == "" {
return usage("empty formId")
}
title := strings.TrimSpace(c.Title)
description := strings.TrimSpace(c.Description)
quiz := strings.TrimSpace(strings.ToLower(c.IsQuiz))
if title == "" && description == "" && quiz == "" {
return usage("at least one of --title, --description, or --quiz is required")
}
if dryRunErr := dryRunExit(ctx, flags, "forms.update", map[string]any{
"form_id": formID,
"title": title,
"description": description,
"quiz": quiz,
}); dryRunErr != nil {
return dryRunErr
}
svc, err := newFormsService(ctx, account)
if err != nil {
return err
}
var requests []*formsapi.Request
if title != "" || description != "" {
info := &formsapi.Info{}
var masks []string
if title != "" {
info.Title = title
masks = append(masks, "title")
}
if description != "" {
info.Description = description
masks = append(masks, "description")
}
requests = append(requests, &formsapi.Request{
UpdateFormInfo: &formsapi.UpdateFormInfoRequest{
Info: info,
UpdateMask: strings.Join(masks, ","),
},
})
}
if quiz != "" {
isQuiz, parseErr := strconv.ParseBool(quiz)
if parseErr != nil {
return usage("--quiz must be true or false")
}
requests = append(requests, &formsapi.Request{
UpdateSettings: &formsapi.UpdateSettingsRequest{
Settings: &formsapi.FormSettings{
QuizSettings: &formsapi.QuizSettings{
IsQuiz: isQuiz,
},
},
UpdateMask: "quizSettings.isQuiz",
},
})
}
batchReq := &formsapi.BatchUpdateFormRequest{
Requests: requests,
IncludeFormInResponse: true,
}
resp, err := svc.Forms.BatchUpdate(formID, batchReq).Context(ctx).Do()
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"updated": true,
"form_id": formID,
"form": resp.Form,
"edit_url": formEditURL(formID),
})
}
u := ui.FromContext(ctx)
u.Out().Printf("updated\ttrue")
printFormSummary(u, resp.Form, formID)
return nil
}

241
internal/cmd/forms_watch.go Normal file
View File

@ -0,0 +1,241 @@
package cmd
import (
"context"
"os"
"strings"
formsapi "google.golang.org/api/forms/v1"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
// FormsWatchCmd groups watch subcommands.
type FormsWatchCmd struct {
Create FormsWatchCreateCmd `cmd:"" name:"create" aliases:"new,add" help:"Create a watch for new responses"`
List FormsWatchListCmd `cmd:"" name:"list" aliases:"ls" help:"List active watches"`
Delete FormsWatchDeleteCmd `cmd:"" name:"delete" aliases:"rm,remove" help:"Delete a watch"`
Renew FormsWatchRenewCmd `cmd:"" name:"renew" aliases:"refresh" help:"Renew a watch (extends 7 days)"`
}
// FormsWatchCreateCmd creates a push notification watch on form responses.
type FormsWatchCreateCmd struct {
FormID string `arg:"" name:"formId" help:"Form ID"`
TopicID string `name:"topic" help:"Cloud Pub/Sub topic name (projects/{project}/topics/{topic})" required:""`
EventType string `name:"event-type" help:"Event type to watch" default:"RESPONSES" enum:"RESPONSES,SCHEMA"`
}
func (c *FormsWatchCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
account, err := requireAccount(flags)
if err != nil {
return err
}
formID := strings.TrimSpace(normalizeGoogleID(c.FormID))
if formID == "" {
return usage("empty formId")
}
topicID := strings.TrimSpace(c.TopicID)
if topicID == "" {
return usage("empty --topic")
}
if dryRunErr := dryRunExit(ctx, flags, "forms.watches.create", map[string]any{
"form_id": formID,
"topic": topicID,
"event_type": c.EventType,
}); dryRunErr != nil {
return dryRunErr
}
svc, err := newFormsService(ctx, account)
if err != nil {
return err
}
req := &formsapi.CreateWatchRequest{
Watch: &formsapi.Watch{
Target: &formsapi.WatchTarget{
Topic: &formsapi.CloudPubsubTopic{
TopicName: topicID,
},
},
EventType: c.EventType,
},
}
watch, err := svc.Forms.Watches.Create(formID, req).Context(ctx).Do()
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"created": true,
"form_id": formID,
"watch": watch,
})
}
u := ui.FromContext(ctx)
u.Out().Printf("created\ttrue")
u.Out().Printf("watch_id\t%s", watch.Id)
u.Out().Printf("form_id\t%s", formID)
u.Out().Printf("event_type\t%s", watch.EventType)
u.Out().Printf("state\t%s", watch.State)
if watch.ExpireTime != "" {
u.Out().Printf("expires\t%s", watch.ExpireTime)
}
return nil
}
// FormsWatchListCmd lists active watches for a form.
type FormsWatchListCmd struct {
FormID string `arg:"" name:"formId" help:"Form ID"`
}
func (c *FormsWatchListCmd) Run(ctx context.Context, flags *RootFlags) error {
account, err := requireAccount(flags)
if err != nil {
return err
}
formID := strings.TrimSpace(normalizeGoogleID(c.FormID))
if formID == "" {
return usage("empty formId")
}
svc, err := newFormsService(ctx, account)
if err != nil {
return err
}
resp, err := svc.Forms.Watches.List(formID).Context(ctx).Do()
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"form_id": formID,
"watches": resp.Watches,
})
}
u := ui.FromContext(ctx)
if len(resp.Watches) == 0 {
u.Out().Println("No active watches.")
return nil
}
u.Out().Println("WATCH_ID\tEVENT_TYPE\tSTATE\tEXPIRES")
for _, w := range resp.Watches {
if w == nil {
continue
}
u.Out().Printf("%s\t%s\t%s\t%s", w.Id, w.EventType, w.State, w.ExpireTime)
}
return nil
}
// FormsWatchDeleteCmd removes a watch.
type FormsWatchDeleteCmd struct {
FormID string `arg:"" name:"formId" help:"Form ID"`
WatchID string `arg:"" name:"watchId" help:"Watch ID"`
}
func (c *FormsWatchDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
account, err := requireAccount(flags)
if err != nil {
return err
}
formID := strings.TrimSpace(normalizeGoogleID(c.FormID))
if formID == "" {
return usage("empty formId")
}
watchID := strings.TrimSpace(c.WatchID)
if watchID == "" {
return usage("empty watchId")
}
if dryRunErr := dryRunExit(ctx, flags, "forms.watches.delete", map[string]any{
"form_id": formID,
"watch_id": watchID,
}); dryRunErr != nil {
return dryRunErr
}
svc, err := newFormsService(ctx, account)
if err != nil {
return err
}
if _, err := svc.Forms.Watches.Delete(formID, watchID).Context(ctx).Do(); err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"deleted": true,
"form_id": formID,
"watch_id": watchID,
})
}
u := ui.FromContext(ctx)
u.Out().Printf("deleted\ttrue")
u.Out().Printf("form_id\t%s", formID)
u.Out().Printf("watch_id\t%s", watchID)
return nil
}
// FormsWatchRenewCmd renews an existing watch for another 7 days.
type FormsWatchRenewCmd struct {
FormID string `arg:"" name:"formId" help:"Form ID"`
WatchID string `arg:"" name:"watchId" help:"Watch ID"`
}
func (c *FormsWatchRenewCmd) Run(ctx context.Context, flags *RootFlags) error {
account, err := requireAccount(flags)
if err != nil {
return err
}
formID := strings.TrimSpace(normalizeGoogleID(c.FormID))
if formID == "" {
return usage("empty formId")
}
watchID := strings.TrimSpace(c.WatchID)
if watchID == "" {
return usage("empty watchId")
}
if dryRunErr := dryRunExit(ctx, flags, "forms.watches.renew", map[string]any{
"form_id": formID,
"watch_id": watchID,
}); dryRunErr != nil {
return dryRunErr
}
svc, err := newFormsService(ctx, account)
if err != nil {
return err
}
watch, err := svc.Forms.Watches.Renew(formID, watchID, &formsapi.RenewWatchRequest{}).Context(ctx).Do()
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"renewed": true,
"form_id": formID,
"watch": watch,
})
}
u := ui.FromContext(ctx)
u.Out().Printf("renewed\ttrue")
u.Out().Printf("watch_id\t%s", watch.Id)
u.Out().Printf("form_id\t%s", formID)
u.Out().Printf("expires\t%s", watch.ExpireTime)
return nil
}