883 lines
32 KiB
Go
883 lines
32 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/alecthomas/kong"
|
|
"google.golang.org/api/calendar/v3"
|
|
|
|
"github.com/steipete/gogcli/internal/ui"
|
|
)
|
|
|
|
type CalendarCreateCmd struct {
|
|
CalendarID string `arg:"" name:"calendarId" help:"Calendar ID"`
|
|
Summary string `name:"summary" help:"Event summary/title"`
|
|
From string `name:"from" help:"Start time (RFC3339)"`
|
|
To string `name:"to" help:"End time (RFC3339)"`
|
|
StartTimezone string `name:"start-timezone" aliases:"from-timezone" help:"IANA timezone metadata for --from (e.g., Europe/Rome)"`
|
|
EndTimezone string `name:"end-timezone" aliases:"to-timezone" help:"IANA timezone metadata for --to (e.g., America/New_York)"`
|
|
Description string `name:"description" help:"Description"`
|
|
Location string `name:"location" help:"Location"`
|
|
Attendees string `name:"attendees" help:"Comma-separated attendee emails"`
|
|
AllDay bool `name:"all-day" help:"All-day event (use date-only in --from/--to)"`
|
|
Recurrence []string `name:"rrule" help:"Recurrence rules (e.g., 'RRULE:FREQ=MONTHLY;BYMONTHDAY=11'). Can be repeated." sep:"none"`
|
|
Reminders []string `name:"reminder" help:"Custom reminders as method:duration (e.g., popup:30m, email:1d). Can be repeated (max 5)."`
|
|
ColorId string `name:"event-color" help:"Event color ID (1-11). Use 'gog calendar colors' to see available colors."`
|
|
Visibility string `name:"visibility" help:"Event visibility: default, public, private, confidential"`
|
|
Transparency string `name:"transparency" help:"Show as busy (opaque) or free (transparent). Aliases: busy, free"`
|
|
SendUpdates string `name:"send-updates" help:"Notification mode: all, externalOnly, none (default: none)"`
|
|
GuestsCanInviteOthers *bool `name:"guests-can-invite" help:"Allow guests to invite others"`
|
|
GuestsCanModify *bool `name:"guests-can-modify" help:"Allow guests to modify event"`
|
|
GuestsCanSeeOthers *bool `name:"guests-can-see-others" help:"Allow guests to see other guests"`
|
|
WithMeet bool `name:"with-meet" help:"Create a Google Meet video conference for this event"`
|
|
SourceUrl string `name:"source-url" help:"URL where event was created/imported from"`
|
|
SourceTitle string `name:"source-title" help:"Title of the source"`
|
|
Attachments []string `name:"attachment" help:"File attachment URL (can be repeated)"`
|
|
PrivateProps []string `name:"private-prop" help:"Private extended property (key=value, can be repeated)"`
|
|
SharedProps []string `name:"shared-prop" help:"Shared extended property (key=value, can be repeated)"`
|
|
EventType string `name:"event-type" help:"Event type: default, focus-time, out-of-office, working-location"`
|
|
FocusAutoDecline string `name:"focus-auto-decline" help:"Focus Time auto-decline mode: none, all, new"`
|
|
FocusDeclineMessage string `name:"focus-decline-message" help:"Focus Time decline message"`
|
|
FocusChatStatus string `name:"focus-chat-status" help:"Focus Time chat status: available, doNotDisturb"`
|
|
OOOAutoDecline string `name:"ooo-auto-decline" help:"Out of Office auto-decline mode: none, all, new"`
|
|
OOODeclineMessage string `name:"ooo-decline-message" help:"Out of Office decline message"`
|
|
WorkingLocationType string `name:"working-location-type" help:"Working location type: home, office, custom"`
|
|
WorkingOfficeLabel string `name:"working-office-label" help:"Working location office name/label"`
|
|
WorkingBuildingId string `name:"working-building-id" help:"Working location building ID"`
|
|
WorkingFloorId string `name:"working-floor-id" help:"Working location floor ID"`
|
|
WorkingDeskId string `name:"working-desk-id" help:"Working location desk ID"`
|
|
WorkingCustomLabel string `name:"working-custom-label" help:"Working location custom label"`
|
|
}
|
|
|
|
func (c *CalendarCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
|
|
plan, err := buildCalendarCreatePlan(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
calendarID, err := prepareCalendarID(plan.CalendarID, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if dryRunErr := dryRunExit(ctx, flags, "calendar.create", map[string]any{
|
|
"calendar_id": calendarID,
|
|
"send_updates": plan.SendUpdates,
|
|
"conference_version_1": plan.WithMeet,
|
|
"supports_attachments": len(plan.Event.Attachments) > 0,
|
|
"event": plan.Event,
|
|
}); dryRunErr != nil {
|
|
return dryRunErr
|
|
}
|
|
|
|
mutation, err := newCalendarMutationContext(ctx, flags, calendarID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
created, err := mutation.insertEvent(ctx, plan.Event, calendarInsertOptions{
|
|
sendUpdates: plan.SendUpdates,
|
|
conferenceVersion1: plan.WithMeet,
|
|
supportsAttachments: len(plan.Event.Attachments) > 0,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return mutation.writeEvent(ctx, created)
|
|
}
|
|
|
|
func (c *CalendarCreateCmd) resolveCreateEventType() (string, error) {
|
|
focusFlags := strings.TrimSpace(c.FocusAutoDecline) != "" ||
|
|
strings.TrimSpace(c.FocusDeclineMessage) != "" ||
|
|
strings.TrimSpace(c.FocusChatStatus) != ""
|
|
oooFlags := strings.TrimSpace(c.OOOAutoDecline) != "" ||
|
|
strings.TrimSpace(c.OOODeclineMessage) != ""
|
|
workingFlags := strings.TrimSpace(c.WorkingLocationType) != "" ||
|
|
strings.TrimSpace(c.WorkingOfficeLabel) != "" ||
|
|
strings.TrimSpace(c.WorkingBuildingId) != "" ||
|
|
strings.TrimSpace(c.WorkingFloorId) != "" ||
|
|
strings.TrimSpace(c.WorkingDeskId) != "" ||
|
|
strings.TrimSpace(c.WorkingCustomLabel) != ""
|
|
|
|
return resolveEventType(c.EventType, focusFlags, oooFlags, workingFlags)
|
|
}
|
|
|
|
func (c *CalendarCreateCmd) defaultSummaryForEventType(eventType string) string {
|
|
switch eventType {
|
|
case eventTypeFocusTime:
|
|
return defaultFocusSummary
|
|
case eventTypeOutOfOffice:
|
|
return defaultOOOSummary
|
|
case eventTypeWorkingLocation:
|
|
return workingLocationSummary(workingLocationInput{
|
|
Type: c.WorkingLocationType,
|
|
OfficeLabel: c.WorkingOfficeLabel,
|
|
CustomLabel: c.WorkingCustomLabel,
|
|
})
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func resolveCreateAllDay(from, to string, allDay bool, eventType string) (bool, error) {
|
|
if eventType != eventTypeWorkingLocation {
|
|
return allDay, nil
|
|
}
|
|
if strings.Contains(from, "T") || strings.Contains(to, "T") {
|
|
return false, usage("working-location requires date-only --from/--to (YYYY-MM-DD)")
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func applyEventTypeTransparencyDefault(transparency, eventType string) string {
|
|
if transparency == "" && (eventType == eventTypeFocusTime || eventType == eventTypeOutOfOffice) {
|
|
return transparencyOpaque
|
|
}
|
|
if transparency == "" && eventType == eventTypeWorkingLocation {
|
|
return transparencyTransparent
|
|
}
|
|
return transparency
|
|
}
|
|
|
|
func applyEventTypeVisibilityDefault(visibility, eventType string) string {
|
|
if visibility == "" && eventType == eventTypeWorkingLocation {
|
|
return visibilityPublic
|
|
}
|
|
return visibility
|
|
}
|
|
|
|
func (c *CalendarCreateCmd) applyCreateEventType(event *calendar.Event, eventType string) error {
|
|
switch eventType {
|
|
case eventTypeDefault:
|
|
event.EventType = eventTypeDefault
|
|
case eventTypeFocusTime:
|
|
props, err := c.buildFocusTimeProperties()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
event.EventType = eventTypeFocusTime
|
|
event.FocusTimeProperties = props
|
|
case eventTypeOutOfOffice:
|
|
props, err := c.buildOutOfOfficeProperties()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
event.EventType = eventTypeOutOfOffice
|
|
event.OutOfOfficeProperties = props
|
|
case eventTypeWorkingLocation:
|
|
props, err := buildWorkingLocationProperties(workingLocationInput{
|
|
Type: c.WorkingLocationType,
|
|
OfficeLabel: c.WorkingOfficeLabel,
|
|
BuildingId: c.WorkingBuildingId,
|
|
FloorId: c.WorkingFloorId,
|
|
DeskId: c.WorkingDeskId,
|
|
CustomLabel: c.WorkingCustomLabel,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
event.EventType = eventTypeWorkingLocation
|
|
event.WorkingLocationProperties = props
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *CalendarCreateCmd) buildFocusTimeProperties() (*calendar.EventFocusTimeProperties, error) {
|
|
return buildFocusTimeProperties(focusTimeInput{
|
|
AutoDecline: c.FocusAutoDecline,
|
|
DeclineMessage: c.FocusDeclineMessage,
|
|
ChatStatus: c.FocusChatStatus,
|
|
})
|
|
}
|
|
|
|
func (c *CalendarCreateCmd) buildOutOfOfficeProperties() (*calendar.EventOutOfOfficeProperties, error) {
|
|
return buildOutOfOfficeProperties(outOfOfficeInput{
|
|
AutoDecline: c.OOOAutoDecline,
|
|
DeclineMessage: c.OOODeclineMessage,
|
|
DeclineMessageProvided: false,
|
|
})
|
|
}
|
|
|
|
type CalendarUpdateCmd struct {
|
|
CalendarID string `arg:"" name:"calendarId" help:"Calendar ID"`
|
|
EventID string `arg:"" name:"eventId" help:"Event ID"`
|
|
Summary string `name:"summary" help:"New summary/title (set empty to clear)"`
|
|
From string `name:"from" help:"New start time (RFC3339; set empty to clear)"`
|
|
To string `name:"to" help:"New end time (RFC3339; set empty to clear)"`
|
|
StartTimezone string `name:"start-timezone" aliases:"from-timezone" help:"IANA timezone metadata for --from (e.g., Europe/Rome)"`
|
|
EndTimezone string `name:"end-timezone" aliases:"to-timezone" help:"IANA timezone metadata for --to (e.g., America/New_York)"`
|
|
Description string `name:"description" help:"New description (set empty to clear)"`
|
|
Location string `name:"location" help:"New location (set empty to clear)"`
|
|
Attendees string `name:"attendees" help:"Comma-separated attendee emails (replaces all; set empty to clear)"`
|
|
AddAttendee string `name:"add-attendee" help:"Comma-separated attendee emails to add (preserves existing attendees)"`
|
|
AllDay bool `name:"all-day" help:"All-day event (use date-only in --from/--to)"`
|
|
Recurrence []string `name:"rrule" help:"Recurrence rules (e.g., 'RRULE:FREQ=MONTHLY;BYMONTHDAY=11'). Can be repeated. Set empty to clear." sep:"none"`
|
|
Reminders []string `name:"reminder" help:"Custom reminders as method:duration (e.g., popup:30m, email:1d). Can be repeated (max 5). Set empty to clear."`
|
|
ColorId string `name:"event-color" help:"Event color ID (1-11, or empty to clear)"`
|
|
Visibility string `name:"visibility" help:"Event visibility: default, public, private, confidential"`
|
|
Transparency string `name:"transparency" help:"Show as busy (opaque) or free (transparent). Aliases: busy, free"`
|
|
GuestsCanInviteOthers *bool `name:"guests-can-invite" help:"Allow guests to invite others"`
|
|
GuestsCanModify *bool `name:"guests-can-modify" help:"Allow guests to modify event"`
|
|
GuestsCanSeeOthers *bool `name:"guests-can-see-others" help:"Allow guests to see other guests"`
|
|
WithMeet bool `name:"with-meet" help:"Create a Google Meet video conference for this event"`
|
|
Scope string `name:"scope" help:"For recurring events: single, future, all" default:"all"`
|
|
OriginalStartTime string `name:"original-start" help:"Original start time of instance (required for scope=single,future)"`
|
|
PrivateProps []string `name:"private-prop" help:"Private extended property (key=value, can be repeated)"`
|
|
SharedProps []string `name:"shared-prop" help:"Shared extended property (key=value, can be repeated)"`
|
|
EventType string `name:"event-type" help:"Event type: default, focus-time, out-of-office, working-location"`
|
|
FocusAutoDecline string `name:"focus-auto-decline" help:"Focus Time auto-decline mode: none, all, new"`
|
|
FocusDeclineMessage string `name:"focus-decline-message" help:"Focus Time decline message (set empty to clear)"`
|
|
FocusChatStatus string `name:"focus-chat-status" help:"Focus Time chat status: available, doNotDisturb"`
|
|
OOOAutoDecline string `name:"ooo-auto-decline" help:"Out of Office auto-decline mode: none, all, new"`
|
|
OOODeclineMessage string `name:"ooo-decline-message" help:"Out of Office decline message (set empty to clear)"`
|
|
WorkingLocationType string `name:"working-location-type" help:"Working location type: home, office, custom"`
|
|
WorkingOfficeLabel string `name:"working-office-label" help:"Working location office name/label"`
|
|
WorkingBuildingId string `name:"working-building-id" help:"Working location building ID"`
|
|
WorkingFloorId string `name:"working-floor-id" help:"Working location floor ID"`
|
|
WorkingDeskId string `name:"working-desk-id" help:"Working location desk ID"`
|
|
WorkingCustomLabel string `name:"working-custom-label" help:"Working location custom label"`
|
|
SendUpdates string `name:"send-updates" help:"Notification mode: all, externalOnly, none (default: none)"`
|
|
}
|
|
|
|
func (c *CalendarUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootFlags) error {
|
|
calendarID, err := prepareCalendarID(c.CalendarID, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
eventID := normalizeCalendarEventID(c.EventID)
|
|
if eventID == "" {
|
|
return usage("empty eventId")
|
|
}
|
|
|
|
scope, err := resolveRecurringScope(c.Scope, c.OriginalStartTime)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// If --all-day changed, require from/to to update both date/time fields.
|
|
if flagProvided(kctx, "all-day") {
|
|
if !flagProvided(kctx, "from") || !flagProvided(kctx, "to") {
|
|
return usage("when changing --all-day, also provide --from and --to")
|
|
}
|
|
}
|
|
|
|
// Cannot use both --attendees and --add-attendee at the same time.
|
|
if flagProvided(kctx, "attendees") && flagProvided(kctx, "add-attendee") {
|
|
return usage("cannot use both --attendees and --add-attendee; use --attendees to replace all, or --add-attendee to add")
|
|
}
|
|
|
|
sendUpdates, err := validateSendUpdates(c.SendUpdates)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
recurrenceProvided := flagProvided(kctx, "rrule")
|
|
|
|
patch, changed, err := c.buildUpdatePatch(kctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
wantsAddAttendee := flagProvided(kctx, "add-attendee")
|
|
if wantsAddAttendee && strings.TrimSpace(c.AddAttendee) == "" {
|
|
return usage("empty --add-attendee")
|
|
}
|
|
|
|
if !changed && !wantsAddAttendee {
|
|
return usage("no updates provided")
|
|
}
|
|
|
|
if dryRunErr := dryRunExit(ctx, flags, "calendar.update", map[string]any{
|
|
"calendar_id": calendarID,
|
|
"event_id": eventID,
|
|
"send_updates": sendUpdates,
|
|
"scope": scope,
|
|
"original_start_time": strings.TrimSpace(c.OriginalStartTime),
|
|
"add_attendee": strings.TrimSpace(c.AddAttendee),
|
|
"patch": patch,
|
|
"wants_add_attendee": wantsAddAttendee,
|
|
"conference_version_1": patch.ConferenceData != nil,
|
|
"supports_attachments": len(patch.Attachments) > 0,
|
|
}); dryRunErr != nil {
|
|
return dryRunErr
|
|
}
|
|
|
|
mutation, err := newCalendarMutationContext(ctx, flags, calendarID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// For --add-attendee, fetch current event to preserve existing attendees with metadata.
|
|
if wantsAddAttendee {
|
|
existing, getErr := mutation.svc.Events.Get(mutation.calendarID, eventID).Context(ctx).Do()
|
|
if getErr != nil {
|
|
return fmt.Errorf("failed to fetch current event: %w", getErr)
|
|
}
|
|
merged, attendeesChanged := mergeAttendeesWithChange(existing.Attendees, c.AddAttendee)
|
|
if attendeesChanged {
|
|
patch.Attendees = merged
|
|
changed = true
|
|
}
|
|
if !changed {
|
|
return usage("no updates provided")
|
|
}
|
|
}
|
|
|
|
targetEventID, parentRecurrence, err := applyUpdateScope(ctx, mutation.svc, mutation.calendarID, eventID, scope, c.OriginalStartTime, patch)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if recurrenceProvided {
|
|
if enrichErr := ensureRecurringPatchDateTimes(ctx, mutation.svc, mutation.calendarID, targetEventID, patch); enrichErr != nil {
|
|
return enrichErr
|
|
}
|
|
}
|
|
|
|
updated, err := mutation.patchEvent(ctx, targetEventID, patch, sendUpdates)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if scope == scopeFuture {
|
|
if err := truncateParentRecurrence(ctx, mutation.svc, mutation.calendarID, eventID, parentRecurrence, c.OriginalStartTime, sendUpdates); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return mutation.writeEvent(ctx, updated)
|
|
}
|
|
|
|
func (c *CalendarUpdateCmd) buildUpdatePatch(kctx *kong.Context) (*calendar.Event, bool, error) {
|
|
patch := &calendar.Event{}
|
|
changed := false
|
|
|
|
eventType, eventTypeRequested, focusFlags, oooFlags, workingFlags, err := c.resolveUpdateEventType(kctx)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
if c.applyTextFields(kctx, patch) {
|
|
changed = true
|
|
}
|
|
|
|
timeChanged, err := c.applyTimeFields(kctx, patch, eventType)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if timeChanged {
|
|
changed = true
|
|
}
|
|
|
|
if c.applyAttendees(kctx, patch) {
|
|
changed = true
|
|
}
|
|
|
|
if c.applyRecurrence(kctx, patch) {
|
|
changed = true
|
|
}
|
|
|
|
remindersChanged, err := c.applyReminders(kctx, patch)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if remindersChanged {
|
|
changed = true
|
|
}
|
|
|
|
displayChanged, err := c.applyDisplayOptions(kctx, patch)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if displayChanged {
|
|
changed = true
|
|
}
|
|
|
|
if c.applyGuestOptions(kctx, patch) {
|
|
changed = true
|
|
}
|
|
|
|
if c.applyConferenceData(kctx, patch) {
|
|
changed = true
|
|
}
|
|
|
|
if c.applyExtendedProperties(kctx, patch) {
|
|
changed = true
|
|
}
|
|
|
|
eventTypeChanged, err := c.applyEventTypeProperties(kctx, patch, eventType, eventTypeRequested, focusFlags, oooFlags, workingFlags)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if eventTypeChanged {
|
|
changed = true
|
|
}
|
|
|
|
return patch, changed, nil
|
|
}
|
|
|
|
func (c *CalendarUpdateCmd) resolveUpdateEventType(kctx *kong.Context) (string, bool, bool, bool, bool, error) {
|
|
focusFlags := flagProvidedAny(kctx, "focus-auto-decline", "focus-decline-message", "focus-chat-status")
|
|
oooFlags := flagProvidedAny(kctx, "ooo-auto-decline", "ooo-decline-message")
|
|
workingFlags := flagProvidedAny(kctx, "working-location-type", "working-office-label", "working-building-id", "working-floor-id", "working-desk-id", "working-custom-label")
|
|
eventType, err := resolveEventType(c.EventType, focusFlags, oooFlags, workingFlags)
|
|
if err != nil {
|
|
return "", false, false, false, false, err
|
|
}
|
|
return eventType, eventType != "", focusFlags, oooFlags, workingFlags, nil
|
|
}
|
|
|
|
func (c *CalendarUpdateCmd) applyTextFields(kctx *kong.Context, patch *calendar.Event) bool {
|
|
changed := false
|
|
if flagProvided(kctx, "summary") {
|
|
patch.Summary = strings.TrimSpace(c.Summary)
|
|
changed = true
|
|
}
|
|
if flagProvided(kctx, "description") {
|
|
patch.Description = strings.TrimSpace(c.Description)
|
|
changed = true
|
|
}
|
|
if flagProvided(kctx, "location") {
|
|
patch.Location = strings.TrimSpace(c.Location)
|
|
changed = true
|
|
}
|
|
return changed
|
|
}
|
|
|
|
func resolveUpdateAllDay(value string, allDay bool, eventType string) (bool, error) {
|
|
if eventType != eventTypeWorkingLocation {
|
|
return allDay, nil
|
|
}
|
|
if strings.Contains(value, "T") {
|
|
return false, usage("working-location requires date-only --from/--to (YYYY-MM-DD)")
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func (c *CalendarUpdateCmd) applyTimeFields(kctx *kong.Context, patch *calendar.Event, eventType string) (bool, error) {
|
|
changed := false
|
|
if flagProvided(kctx, "start-timezone") && !flagProvided(kctx, "from") {
|
|
return false, usage("--start-timezone requires --from")
|
|
}
|
|
if flagProvided(kctx, "end-timezone") && !flagProvided(kctx, "to") {
|
|
return false, usage("--end-timezone requires --to")
|
|
}
|
|
if flagProvided(kctx, "from") {
|
|
allDay, err := resolveUpdateAllDay(c.From, c.AllDay, eventType)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
patch.Start, err = buildEventDateTimeWithTimezone(c.From, allDay, c.StartTimezone, "--start-timezone")
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
changed = true
|
|
}
|
|
if flagProvided(kctx, "to") {
|
|
allDay, err := resolveUpdateAllDay(c.To, c.AllDay, eventType)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
patch.End, err = buildEventDateTimeWithTimezone(c.To, allDay, c.EndTimezone, "--end-timezone")
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
changed = true
|
|
}
|
|
return changed, nil
|
|
}
|
|
|
|
func (c *CalendarUpdateCmd) applyAttendees(kctx *kong.Context, patch *calendar.Event) bool {
|
|
if !flagProvided(kctx, "attendees") {
|
|
return false
|
|
}
|
|
patch.Attendees = buildAttendees(c.Attendees)
|
|
return true
|
|
}
|
|
|
|
func (c *CalendarUpdateCmd) applyRecurrence(kctx *kong.Context, patch *calendar.Event) bool {
|
|
if !flagProvided(kctx, "rrule") {
|
|
return false
|
|
}
|
|
recurrence := buildRecurrence(c.Recurrence)
|
|
if recurrence == nil {
|
|
patch.Recurrence = []string{}
|
|
patch.ForceSendFields = append(patch.ForceSendFields, "Recurrence")
|
|
} else {
|
|
patch.Recurrence = recurrence
|
|
}
|
|
return true
|
|
}
|
|
|
|
func ensureRecurringPatchDateTimes(ctx context.Context, svc *calendar.Service, calendarID, eventID string, patch *calendar.Event) error {
|
|
if len(patch.Recurrence) == 0 {
|
|
return nil
|
|
}
|
|
|
|
patch.Start = normalizeRecurringPatchDateTime(patch.Start, nil)
|
|
patch.End = normalizeRecurringPatchDateTime(patch.End, nil)
|
|
if !recurringPatchDateTimeNeedsFetch(patch.Start) && !recurringPatchDateTimeNeedsFetch(patch.End) {
|
|
return nil
|
|
}
|
|
|
|
current, err := svc.Events.Get(calendarID, eventID).Context(ctx).Do()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to fetch current event for recurrence timezone: %w", err)
|
|
}
|
|
|
|
patch.Start = normalizeRecurringPatchDateTime(patch.Start, current.Start)
|
|
patch.End = normalizeRecurringPatchDateTime(patch.End, current.End)
|
|
return nil
|
|
}
|
|
|
|
func recurringPatchDateTimeNeedsFetch(dt *calendar.EventDateTime) bool {
|
|
if dt == nil {
|
|
return true
|
|
}
|
|
if strings.TrimSpace(dt.Date) != "" {
|
|
return false
|
|
}
|
|
return strings.TrimSpace(dt.DateTime) == "" || strings.TrimSpace(dt.TimeZone) == ""
|
|
}
|
|
|
|
func normalizeRecurringPatchDateTime(primary, fallback *calendar.EventDateTime) *calendar.EventDateTime {
|
|
if primary == nil && fallback == nil {
|
|
return nil
|
|
}
|
|
|
|
var out *calendar.EventDateTime
|
|
if primary != nil {
|
|
out = cloneEventDateTime(primary)
|
|
} else {
|
|
out = cloneEventDateTime(fallback)
|
|
}
|
|
if out == nil {
|
|
return nil
|
|
}
|
|
|
|
if strings.TrimSpace(out.Date) != "" {
|
|
out.DateTime = ""
|
|
out.TimeZone = ""
|
|
return out
|
|
}
|
|
if strings.TrimSpace(out.DateTime) == "" && fallback != nil {
|
|
if strings.TrimSpace(fallback.Date) != "" {
|
|
return &calendar.EventDateTime{Date: fallback.Date}
|
|
}
|
|
out.DateTime = fallback.DateTime
|
|
}
|
|
if strings.TrimSpace(out.TimeZone) == "" && fallback != nil {
|
|
out.TimeZone = strings.TrimSpace(fallback.TimeZone)
|
|
}
|
|
if strings.TrimSpace(out.TimeZone) == "" && strings.TrimSpace(out.DateTime) != "" {
|
|
out.TimeZone = extractTimezone(out.DateTime)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func cloneEventDateTime(in *calendar.EventDateTime) *calendar.EventDateTime {
|
|
if in == nil {
|
|
return nil
|
|
}
|
|
return &calendar.EventDateTime{
|
|
Date: in.Date,
|
|
DateTime: in.DateTime,
|
|
TimeZone: in.TimeZone,
|
|
}
|
|
}
|
|
|
|
func (c *CalendarUpdateCmd) applyReminders(kctx *kong.Context, patch *calendar.Event) (bool, error) {
|
|
if !flagProvided(kctx, "reminder") {
|
|
return false, nil
|
|
}
|
|
reminders, err := buildReminders(c.Reminders)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if reminders == nil {
|
|
patch.Reminders = &calendar.EventReminders{UseDefault: true}
|
|
patch.ForceSendFields = append(patch.ForceSendFields, "Reminders")
|
|
} else {
|
|
patch.Reminders = reminders
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func (c *CalendarUpdateCmd) applyDisplayOptions(kctx *kong.Context, patch *calendar.Event) (bool, error) {
|
|
changed := false
|
|
if flagProvided(kctx, "event-color") {
|
|
colorId, err := validateColorId(c.ColorId)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
patch.ColorId = colorId
|
|
changed = true
|
|
}
|
|
if flagProvided(kctx, "visibility") {
|
|
visibility, err := validateVisibility(c.Visibility)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
patch.Visibility = visibility
|
|
changed = true
|
|
}
|
|
if flagProvided(kctx, "transparency") {
|
|
transparency, err := validateTransparency(c.Transparency)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
patch.Transparency = transparency
|
|
changed = true
|
|
}
|
|
return changed, nil
|
|
}
|
|
|
|
func (c *CalendarUpdateCmd) applyGuestOptions(kctx *kong.Context, patch *calendar.Event) bool {
|
|
changed := false
|
|
if flagProvided(kctx, "guests-can-invite") {
|
|
if c.GuestsCanInviteOthers != nil {
|
|
patch.GuestsCanInviteOthers = c.GuestsCanInviteOthers
|
|
}
|
|
patch.ForceSendFields = append(patch.ForceSendFields, "GuestsCanInviteOthers")
|
|
changed = true
|
|
}
|
|
if flagProvided(kctx, "guests-can-modify") {
|
|
if c.GuestsCanModify != nil {
|
|
patch.GuestsCanModify = *c.GuestsCanModify
|
|
}
|
|
patch.ForceSendFields = append(patch.ForceSendFields, "GuestsCanModify")
|
|
changed = true
|
|
}
|
|
if flagProvided(kctx, "guests-can-see-others") {
|
|
if c.GuestsCanSeeOthers != nil {
|
|
patch.GuestsCanSeeOtherGuests = c.GuestsCanSeeOthers
|
|
}
|
|
patch.ForceSendFields = append(patch.ForceSendFields, "GuestsCanSeeOtherGuests")
|
|
changed = true
|
|
}
|
|
return changed
|
|
}
|
|
|
|
func (c *CalendarUpdateCmd) applyConferenceData(kctx *kong.Context, patch *calendar.Event) bool {
|
|
if !flagProvided(kctx, "with-meet") {
|
|
return false
|
|
}
|
|
patch.ConferenceData = buildConferenceData(true)
|
|
return true
|
|
}
|
|
|
|
func (c *CalendarUpdateCmd) applyExtendedProperties(kctx *kong.Context, patch *calendar.Event) bool {
|
|
if !flagProvided(kctx, "private-prop") && !flagProvided(kctx, "shared-prop") {
|
|
return false
|
|
}
|
|
patch.ExtendedProperties = buildExtendedProperties(c.PrivateProps, c.SharedProps)
|
|
return true
|
|
}
|
|
|
|
func (c *CalendarUpdateCmd) applyEventTypeProperties(kctx *kong.Context, patch *calendar.Event, eventType string, eventTypeRequested, focusFlags, oooFlags, workingFlags bool) (bool, error) {
|
|
changed := false
|
|
if eventTypeRequested {
|
|
patch.EventType = eventType
|
|
changed = true
|
|
if eventType == eventTypeDefault {
|
|
patch.NullFields = append(patch.NullFields, "FocusTimeProperties", "OutOfOfficeProperties", "WorkingLocationProperties")
|
|
}
|
|
}
|
|
if eventTypeRequested && !flagProvided(kctx, "transparency") &&
|
|
(eventType == eventTypeFocusTime || eventType == eventTypeOutOfOffice) {
|
|
patch.Transparency = transparencyOpaque
|
|
changed = true
|
|
}
|
|
if eventTypeRequested && !flagProvided(kctx, "transparency") && eventType == eventTypeWorkingLocation {
|
|
patch.Transparency = transparencyTransparent
|
|
changed = true
|
|
}
|
|
if eventTypeRequested && !flagProvided(kctx, "visibility") && eventType == eventTypeWorkingLocation {
|
|
patch.Visibility = visibilityPublic
|
|
changed = true
|
|
}
|
|
|
|
switch eventType {
|
|
case eventTypeFocusTime:
|
|
if eventTypeRequested || focusFlags {
|
|
props, err := c.buildUpdateFocusTimeProperties()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
patch.FocusTimeProperties = props
|
|
changed = true
|
|
}
|
|
case eventTypeOutOfOffice:
|
|
if eventTypeRequested || oooFlags {
|
|
props, err := c.buildUpdateOutOfOfficeProperties(flagProvided(kctx, "ooo-decline-message"))
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
patch.OutOfOfficeProperties = props
|
|
changed = true
|
|
}
|
|
case eventTypeWorkingLocation:
|
|
if eventTypeRequested || workingFlags {
|
|
props, err := buildWorkingLocationProperties(workingLocationInput{
|
|
Type: c.WorkingLocationType,
|
|
OfficeLabel: c.WorkingOfficeLabel,
|
|
BuildingId: c.WorkingBuildingId,
|
|
FloorId: c.WorkingFloorId,
|
|
DeskId: c.WorkingDeskId,
|
|
CustomLabel: c.WorkingCustomLabel,
|
|
})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
patch.WorkingLocationProperties = props
|
|
changed = true
|
|
}
|
|
}
|
|
return changed, nil
|
|
}
|
|
|
|
func (c *CalendarUpdateCmd) buildUpdateFocusTimeProperties() (*calendar.EventFocusTimeProperties, error) {
|
|
return buildFocusTimeProperties(focusTimeInput{
|
|
AutoDecline: c.FocusAutoDecline,
|
|
DeclineMessage: c.FocusDeclineMessage,
|
|
ChatStatus: c.FocusChatStatus,
|
|
})
|
|
}
|
|
|
|
func (c *CalendarUpdateCmd) buildUpdateOutOfOfficeProperties(declineProvided bool) (*calendar.EventOutOfOfficeProperties, error) {
|
|
return buildOutOfOfficeProperties(outOfOfficeInput{
|
|
AutoDecline: c.OOOAutoDecline,
|
|
DeclineMessage: c.OOODeclineMessage,
|
|
DeclineMessageProvided: declineProvided,
|
|
})
|
|
}
|
|
|
|
func applyUpdateScope(ctx context.Context, svc *calendar.Service, calendarID, eventID, scope, originalStartTime string, patch *calendar.Event) (string, []string, error) {
|
|
resolution, err := resolveRecurringScopeResolution(ctx, svc, calendarID, eventID, scope, originalStartTime)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
if scope == scopeFuture {
|
|
parentRecurrence := resolution.ParentRecurrence
|
|
recurrenceOverride := len(patch.Recurrence) > 0
|
|
if !recurrenceOverride {
|
|
for _, field := range patch.ForceSendFields {
|
|
if field == "Recurrence" {
|
|
recurrenceOverride = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !recurrenceOverride {
|
|
patch.Recurrence = parentRecurrence
|
|
}
|
|
}
|
|
|
|
return resolution.TargetEventID, resolution.ParentRecurrence, nil
|
|
}
|
|
|
|
func truncateParentRecurrence(ctx context.Context, svc *calendar.Service, calendarID, eventID string, parentRecurrence []string, originalStartTime, sendUpdates string) error {
|
|
truncated, err := truncateRecurrence(parentRecurrence, originalStartTime)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
call := svc.Events.Patch(calendarID, eventID, &calendar.Event{Recurrence: truncated}).Context(ctx)
|
|
if sendUpdates != "" {
|
|
call = call.SendUpdates(sendUpdates)
|
|
}
|
|
_, err = call.Do()
|
|
return err
|
|
}
|
|
|
|
func resolveRecurringScope(scopeValue, originalStartTime string) (string, error) {
|
|
scope := strings.TrimSpace(strings.ToLower(scopeValue))
|
|
if scope == "" {
|
|
scope = scopeAll
|
|
}
|
|
switch scope {
|
|
case scopeSingle, scopeFuture:
|
|
if strings.TrimSpace(originalStartTime) == "" {
|
|
return "", usage(fmt.Sprintf("--original-start required when --scope=%s", scope))
|
|
}
|
|
case scopeAll:
|
|
default:
|
|
return "", fmt.Errorf("invalid scope: %q (must be single, future, or all)", scope)
|
|
}
|
|
return scope, nil
|
|
}
|
|
|
|
type CalendarDeleteCmd struct {
|
|
CalendarID string `arg:"" name:"calendarId" help:"Calendar ID"`
|
|
EventID string `arg:"" name:"eventId" help:"Event ID"`
|
|
Scope string `name:"scope" help:"For recurring events: single, future, all" default:"all"`
|
|
OriginalStartTime string `name:"original-start" help:"Original start time of instance (required for scope=single,future)"`
|
|
SendUpdates string `name:"send-updates" help:"Notification mode: all, externalOnly, none (default: none)"`
|
|
}
|
|
|
|
func (c *CalendarDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
|
|
u := ui.FromContext(ctx)
|
|
calendarID, err := prepareCalendarID(c.CalendarID, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
eventID := normalizeCalendarEventID(c.EventID)
|
|
if eventID == "" {
|
|
return usage("empty eventId")
|
|
}
|
|
|
|
scope, err := resolveRecurringScope(c.Scope, c.OriginalStartTime)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
sendUpdates, err := validateSendUpdates(c.SendUpdates)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
confirmMessage := fmt.Sprintf("delete event %s from calendar %s", eventID, calendarID)
|
|
if scope == scopeSingle {
|
|
confirmMessage = fmt.Sprintf("delete event %s (instance start %s) from calendar %s", eventID, c.OriginalStartTime, calendarID)
|
|
}
|
|
if scope == scopeFuture {
|
|
confirmMessage = fmt.Sprintf("delete event %s (instance start %s) and all following from calendar %s", eventID, c.OriginalStartTime, calendarID)
|
|
}
|
|
if confirmErr := dryRunAndConfirmDestructive(ctx, flags, "calendar.delete", map[string]any{
|
|
"calendar_id": calendarID,
|
|
"event_id": eventID,
|
|
"scope": scope,
|
|
"original_start": c.OriginalStartTime,
|
|
"send_updates": sendUpdates,
|
|
}, confirmMessage); confirmErr != nil {
|
|
return confirmErr
|
|
}
|
|
|
|
mutation, err := newCalendarMutationContext(ctx, flags, calendarID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
resolution, err := resolveRecurringScopeResolution(ctx, mutation.svc, mutation.calendarID, eventID, scope, c.OriginalStartTime)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := mutation.deleteEvent(ctx, resolution.TargetEventID, sendUpdates); err != nil {
|
|
return err
|
|
}
|
|
if scope == scopeFuture {
|
|
truncated, truncateErr := truncateRecurrence(resolution.ParentRecurrence, c.OriginalStartTime)
|
|
if truncateErr != nil {
|
|
return truncateErr
|
|
}
|
|
_, patchErr := mutation.patchEvent(ctx, resolution.ParentEventID, &calendar.Event{Recurrence: truncated}, sendUpdates)
|
|
if patchErr != nil {
|
|
return patchErr
|
|
}
|
|
}
|
|
return writeResult(ctx, u,
|
|
kv("deleted", true),
|
|
kv("calendarId", mutation.calendarID),
|
|
kv("eventId", resolution.TargetEventID),
|
|
)
|
|
}
|