Files
larksuite__cli/shortcuts/calendar/calendar_update_recurring.go
hugang-lark 1d6e7731d3 feat: add shortcut for +list-attendees (#2591)
feat: optimize +freebusy shortcut

fix: hint timezone

feat: operate recurrence event
2026-09-01 19:45:42 +08:00

844 lines
31 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// calendar +update: --apply-to=all and --apply-to=this-and-following
// implementations. Split from calendar_update.go so the historical single-event
// path stays isolated.
package calendar
import (
"context"
"fmt"
"io"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const updateLogPrefix = "[calendar +update]"
// executeCalendarUpdateAll implements --apply-to=all.
//
// - If the caller changed --start/--end (time change), exceptions are dropped
// first (their original occurrence slot is now meaningless), then the
// master is patched with the new time.
// - Otherwise the caller changed non-time fields (summary, description,
// rrule, attendees). We apply the exact same PATCH / attendee-batch calls
// to every exception first, then to the master. Only fields the caller
// explicitly set are propagated — the exception's independent customization
// of other fields is preserved.
func executeCalendarUpdateAll(ctx context.Context, runtime *common.RuntimeContext, calendarID string, current *calendarEvent, targetEventID string) error {
master, err := ensureMasterEvent(runtime, calendarID, current, targetEventID)
if err != nil {
return err
}
masterID := master.EventID
if masterID == "" {
masterID = masterEventID(targetEventID)
master.EventID = masterID
}
if !runtime.Bool(flagSkipRoomCheck) {
// Reuse the existing precheck against the master; the precheck understands
// time and rrule change semantics.
body, _, buildErr := buildCalendarUpdateEventData(runtime)
if buildErr != nil {
return buildErr
}
if err := runRoomAvailabilityPrecheck(ctx, runtime, calendarID, masterID, body); err != nil {
return err
}
}
window, err := recurringWindowFromMaster(master, time.Now())
if err != nil {
return err
}
timeChanged, err := masterTimeChanged(runtime, master)
if err != nil {
return err
}
// A time-change apply-to=all also destroys exceptions (deleteException=true,
// i.e. is_deleted=true), so scan cancelled placeholders too — they need to
// be destroyed for the same reason live ones do. A non-time PATCH walks the
// same rows to replay the field changes, and there is nothing to replay
// onto a cancelled row.
exceptions, err := listExceptionsInWindow(ctx, runtime, calendarID, masterID, window, timeChanged)
if err != nil {
return err
}
nowSec := time.Now().Unix()
future, past := splitFutureThenPast(exceptions, nowSec)
errOut := runtime.IO().ErrOut
var worker *exceptionWorker
if len(exceptions) > 0 {
if timeChanged {
fmt.Fprintf(errOut, "%s time change: deleting %d exception(s) first, then patching master %s\n",
updateLogPrefix, len(exceptions), masterID)
worker, err = runExceptionDeletion(ctx, runtime, calendarID, future, past)
if err != nil {
return err
}
} else {
fmt.Fprintf(errOut, "%s non-time change: patching %d exception(s) with the same fields, then the master %s\n",
updateLogPrefix, len(exceptions), masterID)
worker, err = runExceptionPatch(ctx, runtime, calendarID, future, past)
if err != nil {
return err
}
}
}
fmt.Fprintf(errOut, "%s patching master event %s\n", updateLogPrefix, masterID)
event, addedCount, removedCount, err := applyUpdateToEvent(runtime, calendarID, masterID, !timeChanged)
if err != nil {
return withStepContext(err, "failed to update master event %s", masterID)
}
updatedEvent := calendarUpdateResult(masterID, event, addedCount, removedCount)
updatedEvent["action"] = "patched"
result := map[string]interface{}{
"calendar_id": calendarID,
"apply_to": applyToAll,
"updated_event": updatedEvent,
}
if worker != nil {
exceptionsSummary := map[string]interface{}{
"total": len(exceptions),
"updated": int(worker.processed.Load()),
"failed": int(worker.failed.Load()),
}
if failures := worker.Failures(); len(failures) > 0 {
exceptionsSummary["failures"] = failures
}
result["exceptions"] = exceptionsSummary
}
runtime.OutFormat(result, nil, func(w io.Writer) {
writeUpdatePretty(w, result, targetEventID, applyToAll, "updated_event", "Updated event", "Exceptions updated")
})
return nil
}
// writeUpdatePretty renders the +update pretty output in the shape the user
// asked for: (1) header stating which apply_to scope ran on which event_id,
// (2) the updated master/single event details, (3) an optional exceptions
// summary. eventKey is the top-level key in `result` that carries the event
// details ("updated_event"); eventLabel and exceptionsLabel are section titles.
func writeUpdatePretty(w io.Writer, result map[string]interface{}, targetEventID, scope, eventKey, eventLabel, exceptionsLabel string) {
fmt.Fprintf(w, "Applied `%s` on %s\n\n", scope, targetEventID)
if ev, ok := result[eventKey].(map[string]interface{}); ok {
fmt.Fprintf(w, "%s:\n", eventLabel)
output.PrintTable(w, []map[string]interface{}{ev})
fmt.Fprintln(w)
}
if exceptions, ok := result["exceptions"].(map[string]interface{}); ok {
fmt.Fprintf(w, "%s:\n", exceptionsLabel)
output.PrintTable(w, []map[string]interface{}{exceptions})
fmt.Fprintln(w)
}
fmt.Fprintln(w, "Event updated successfully")
}
// runExceptionDeletion deletes every exception in future-first order using the
// shared worker and returns the worker so the caller can surface Failures().
// A returned error indicates only context cancellation; per-item failures do
// not abort the batch. delete_exception=true so each row is destroyed
// (is_deleted=true) rather than left as a cancelled placeholder — this path
// only runs when a series-wide time change has invalidated the original
// occurrence slot, so a lingering cancelled marker would only add noise to a
// subsequent series-view.
func runExceptionDeletion(ctx context.Context, runtime *common.RuntimeContext, calendarID string, future, past []exceptionInstance) (*exceptionWorker, error) {
worker := &exceptionWorker{
Concurrency: 3,
Runtime: runtime,
Label: updateLogPrefix,
Total: len(future) + len(past),
Do: func(_ context.Context, ex exceptionInstance) error {
return deleteEventOnce(runtime, calendarID, ex.EventID, false, true)
},
}
if err := worker.Run(ctx, future); err != nil {
return worker, err
}
if err := worker.Run(ctx, past); err != nil {
return worker, err
}
worker.finalSummary(runtime.IO().ErrOut, "deleted")
return worker, nil
}
// runExceptionPatch applies the same field-level update (built from the same
// flags) to every exception. Attendee add/remove is replayed per exception —
// participants have separate acceptance state, so a name/description change
// stays semantically consistent while attendee changes propagate to the
// series as a whole. Per-item failures are recorded on the worker and do not
// abort the batch.
//
// This runs only in the apply-to=all non-time branch (a real time change
// deletes exceptions instead). It therefore always strips start_time/end_time
// from the PATCH body — even when the caller passed --start/--end, they must
// have echoed the master's time (that's why we're here), and pushing the
// master's time onto every exception would overwrite each exception's own
// occurrence slot.
func runExceptionPatch(ctx context.Context, runtime *common.RuntimeContext, calendarID string, future, past []exceptionInstance) (*exceptionWorker, error) {
worker := &exceptionWorker{
Concurrency: 3,
Runtime: runtime,
Label: updateLogPrefix,
Total: len(future) + len(past),
Do: func(_ context.Context, ex exceptionInstance) error {
if _, _, _, err := applyUpdateToEvent(runtime, calendarID, ex.EventID, true); err != nil {
return err
}
return nil
},
}
if err := worker.Run(ctx, future); err != nil {
return worker, err
}
if err := worker.Run(ctx, past); err != nil {
return worker, err
}
worker.finalSummary(runtime.IO().ErrOut, "patched")
return worker, nil
}
// masterTimeChanged reports whether --start / --end (once parsed) actually
// differ from the master's stored time. A caller that re-passes the master's
// current time — common when a script echoes the fetched event back — should
// not force the "time change" branch, which would delete every exception. The
// caller must have set both flags; --start and --end are wired to travel
// together upstream. Returns false when either flag was omitted or the input
// exactly matches the master.
//
// Timed and all-day masters carry different fields (`timestamp` vs `date`), and
// common.ParseTime normalises date-only input against time.Local, whereas Lark
// stores all-day dates at UTC midnight (see masterStartUnix). Comparing the two
// via unix seconds therefore never matches for a legitimate echo of an all-day
// event. We branch on the master shape: all-day masters compare on the raw
// YYYY-MM-DD date, timed masters compare on the unix-second string.
func masterTimeChanged(runtime *common.RuntimeContext, master *calendarEvent) (bool, error) {
if !(runtime.Cmd.Flags().Changed("start") && runtime.Cmd.Flags().Changed("end")) {
return false, nil
}
if master == nil || master.StartTime == nil || master.EndTime == nil {
return true, nil
}
startInput := strings.TrimSpace(runtime.Str("start"))
endInput := strings.TrimSpace(runtime.Str("end"))
if isAllDayEvent(master) {
// All-day master: only a bare YYYY-MM-DD input can echo the stored
// `date` unchanged. A timestamped or datetime input inherently drops
// all-day semantics and is a real time change.
startDate, ok := parseDateOnly(startInput, time.UTC)
if !ok {
return true, nil
}
endDate, ok := parseDateOnly(endInput, time.UTC)
if !ok {
return true, nil
}
return !(startDate == master.StartTime.Date && endDate == master.EndTime.Date), nil
}
startTs, err := common.ParseTime(startInput)
if err != nil {
return false, errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
}
endTs, err := common.ParseTime(endInput, "end")
if err != nil {
return false, errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
}
return !(startTs == master.StartTime.Timestamp && endTs == master.EndTime.Timestamp), nil
}
// parseDateOnly returns the canonical YYYY-MM-DD form when s is exactly a
// date (no time component). Used to compare user input against an all-day
// master's stored `date` without going through timezone-sensitive unix math.
func parseDateOnly(s string, loc *time.Location) (string, bool) {
t, err := time.ParseInLocation("2006-01-02", s, loc)
if err != nil {
return "", false
}
return t.Format("2006-01-02"), true
}
// applyUpdateToEvent runs the field PATCH + attendee add/remove trio against a
// single event id. Extracted so the master and every exception share exactly
// the same code path — including field selection logic (only user-set flags
// are propagated).
//
// omitTime, when true, strips start_time/end_time from the PATCH body before
// sending. Used by the apply-to=all non-time branch: masterTimeChanged already
// confirmed the caller's --start/--end just echo the master's stored time
// (common when a script fetches the event and passes it back), so those flags
// were logically not a time change — but buildCalendarUpdateEventData only
// knows the flag was set. Sending the master's time on to every exception PATCH
// would overwrite each exception's own occurrence slot with the master's first
// instance time, which is the bug this parameter guards against. The single-
// event and time-change paths pass false; only the non-time apply-to=all path
// needs the strip.
func applyUpdateToEvent(runtime *common.RuntimeContext, calendarID, eventID string, omitTime bool) (map[string]interface{}, int, int, error) {
body, hasEventFields, err := buildCalendarUpdateEventData(runtime)
if err != nil {
return nil, 0, 0, err
}
if omitTime {
delete(body, "start_time")
delete(body, "end_time")
delete(body, "need_notification")
hasEventFields = len(body) > 0
if hasEventFields {
body["need_notification"] = runtime.Bool("notify")
}
}
event := map[string]interface{}{}
if hasEventFields {
data, err := runtime.CallAPITyped("PATCH", calendarUpdateEventPath(calendarID, eventID),
map[string]interface{}{"user_id_type": "open_id"}, body)
if err != nil {
return nil, 0, 0, err
}
if v, _ := data["event"].(map[string]interface{}); v != nil {
event = v
}
}
removed := 0
if removeStr := runtime.Str("remove-attendee-ids"); strings.TrimSpace(removeStr) != "" {
deleteIDs, err := attendeeDeleteIDs(removeStr)
if err != nil {
return nil, 0, 0, err
}
_, err = runtime.CallAPITyped("POST", calendarUpdateAttendeesPath(calendarID, eventID)+"/batch_delete",
map[string]interface{}{"user_id_type": "open_id"},
map[string]interface{}{"delete_ids": deleteIDs, "need_notification": runtime.Bool("notify")})
if err != nil {
return nil, 0, 0, err
}
removed = len(deleteIDs)
}
added := 0
if addStr := runtime.Str("add-attendee-ids"); strings.TrimSpace(addStr) != "" {
attendees, err := parseAttendees(addStr, "")
if err != nil {
return nil, 0, 0, withParam(err, "--add-attendee-ids")
}
_, err = runtime.CallAPITyped("POST", calendarUpdateAttendeesPath(calendarID, eventID),
map[string]interface{}{"user_id_type": "open_id"},
map[string]interface{}{"attendees": attendees, "need_notification": runtime.Bool("notify")})
if err != nil {
return nil, 0, 0, err
}
added = len(attendees)
}
return event, added, removed, nil
}
// executeCalendarUpdateThisAndFollowing implements --apply-to=this-and-following.
//
// Steps:
// 1. delete exceptions on/after the pivot instance
// 2. PATCH the master's rrule with UNTIL = pivot - 1s (truncating the old
// series before the pivot)
// 3. POST a new event starting at the pivot instance, inheriting the master's
// summary/description/attendees/vchat/reminders/location, overlaid by any
// flag values the caller passed. This is the "carry the edits forward"
// step users expect from Google/Apple.
func executeCalendarUpdateThisAndFollowing(ctx context.Context, runtime *common.RuntimeContext, calendarID string, current *calendarEvent, targetEventID string) error {
pivotUnix, ok := parseInstanceOriginalTime(targetEventID)
if !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--%s=%s needs a recurring instance event_id (shape `{uid}_{originalTime}` with a positive originalTime); got %q",
flagApplyTo, applyToThisAndFollowing, targetEventID).WithParam("--event-id")
}
master, err := ensureMasterEvent(runtime, calendarID, current, targetEventID)
if err != nil {
return err
}
masterID := master.EventID
if masterID == "" {
masterID = masterEventID(targetEventID)
master.EventID = masterID
}
if strings.TrimSpace(master.Recurrence) == "" {
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"master event %s has no recurrence rule; cannot truncate", masterID)
}
// The scan window starts at the pivot day's midnight (in the event's
// timezone) — every exception with originalTime >= pivot is on that same
// day or later, so the scan is bounded to the tail of the series.
loc := eventLocation(master)
pivotDay := pivotDayMidnight(pivotUnix, loc)
windowEnd := recurringEndFromRRule(master.Recurrence, pivotDay.Unix(), time.Now())
if windowEnd <= pivotDay.Unix() {
windowEnd = pivotDay.Unix()
}
// this-and-following always destroys exceptions on/after the pivot
// (deleteException=true → is_deleted=true), so include cancelled
// placeholders in the scan so they get destroyed alongside the live rows.
all, err := listExceptionsInWindow(ctx, runtime, calendarID, masterID,
recurringWindow{Start: pivotDay.Unix(), End: windowEnd}, true)
if err != nil {
return err
}
future := filterOnOrAfter(all, pivotUnix)
errOut := runtime.IO().ErrOut
var worker *exceptionWorker
if len(future) > 0 {
fmt.Fprintf(errOut, "%s this-and-following: deleting %d exception(s) on/after pivot %d (%s)\n",
updateLogPrefix, len(future), pivotUnix, formatPivotDatetime(pivotUnix, loc))
worker, err = runExceptionDeletion(ctx, runtime, calendarID, future, nil)
if err != nil {
return err
}
}
truncatedRRule := truncateRecurrenceUntil(master.Recurrence, pivotDay)
fmt.Fprintf(errOut, "%s truncating master rrule at %s (UNTIL=%s)\n",
updateLogPrefix, pivotDay.Format(time.RFC3339), truncatedRRule)
patchResp, err := runtime.CallAPITyped("PATCH", calendarUpdateEventPath(calendarID, masterID),
map[string]interface{}{"user_id_type": "open_id"},
map[string]interface{}{
"recurrence": truncatedRRule,
"need_notification": runtime.Bool("notify"),
})
if err != nil {
return withStepContext(err, "future exceptions deleted but master rrule truncation failed for %s", masterID)
}
truncatedMaster, err := parseCalendarEvent(patchResp)
if err != nil {
return withStepContext(err, "master rrule truncated but response parsing failed for %s", masterID)
}
newEventID, newEvent, addedCount, removedCount, err := createFollowingSeries(runtime, calendarID, master, current, pivotUnix)
if err != nil {
return withStepContext(err, "master truncated but the new (following) series could not be created; you may need to recreate it manually with `+create`")
}
fmt.Fprintf(errOut, "%s created new following series event_id=%s\n", updateLogPrefix, newEventID)
truncatedEvent := map[string]interface{}{
"master_event_id": masterID,
"rrule_truncated": truncatedRRule,
}
if start := formatCalendarEventTime(eventTimeAsMap(truncatedMaster.StartTime)); start != "" {
truncatedEvent["start"] = start
}
if end := formatCalendarEventTime(eventTimeAsMap(truncatedMaster.EndTime)); end != "" {
truncatedEvent["end"] = end
}
followEvent := calendarUpdateResult(newEventID, newEvent, addedCount, removedCount)
if rrule, _ := newEvent["recurrence"].(string); rrule != "" {
followEvent["rrule"] = rrule
}
result := map[string]interface{}{
"calendar_id": calendarID,
"apply_to": applyToThisAndFollowing,
"truncated_event": truncatedEvent,
"follow_event": followEvent,
}
if worker != nil {
exceptions := map[string]interface{}{
"total": len(future),
"deleted": int(worker.processed.Load()),
"failed": int(worker.failed.Load()),
}
if failures := worker.Failures(); len(failures) > 0 {
exceptions["failures"] = failures
}
result["exceptions"] = exceptions
}
runtime.OutFormat(result, nil, func(w io.Writer) {
writeThisAndFollowingPretty(w, result, targetEventID)
})
return nil
}
// writeThisAndFollowingPretty renders the three sections users expect in this
// order (only the ones with content):
// 1. Header: which apply_to scope was executed on which event_id.
// 2. Truncated event: the pivot-instance id, its master id, and the master's
// new (truncated) recurrence rule.
// 3. Follow-up event: the newly created series' event_id, start/end (RFC3339),
// rrule, attendee counts, summary/description.
// 4. Exception deletion summary (skipped when there was nothing to delete).
func writeThisAndFollowingPretty(w io.Writer, result map[string]interface{}, targetEventID string) {
fmt.Fprintf(w, "Applied `%s` on %s\n\n", applyToThisAndFollowing, targetEventID)
if truncated, ok := result["truncated_event"].(map[string]interface{}); ok {
fmt.Fprintln(w, "Truncated event:")
output.PrintTable(w, []map[string]interface{}{truncated})
fmt.Fprintln(w)
}
if follow, ok := result["follow_event"].(map[string]interface{}); ok {
fmt.Fprintln(w, "Follow-up event:")
output.PrintTable(w, []map[string]interface{}{follow})
fmt.Fprintln(w)
}
if exceptions, ok := result["exceptions"].(map[string]interface{}); ok {
fmt.Fprintln(w, "Exceptions removed:")
output.PrintTable(w, []map[string]interface{}{exceptions})
fmt.Fprintln(w)
}
fmt.Fprintln(w, "Recurring series updated successfully")
}
// createFollowingSeries assembles the request body for the new "following"
// series and POSTs /events. Inheritance rules (base = master, overlaid by
// caller flags):
// - summary / description: caller flag when Changed(), else master value
// - start / end: caller flag when Changed(), else the pivot instance's
// start/end (so the new series begins exactly where the old one was
// truncated)
// - rrule: caller flag when Changed(), else the master's original rrule
// with its UNTIL kept verbatim and COUNT dropped. UNTIL is a fixed
// absolute date so it copies cleanly; COUNT anchors on the old dtstart
// and has no meaning against a new pivot.
// - vchat / reminders / attendee_ability / free_busy_status / location /
// visibility: inherited from the master untouched.
//
// The create endpoint does not accept attendees inline, so participants and
// meeting rooms are propagated in a follow-up POST /attendees: the master's
// existing attendees are fetched and re-added on the new series, then
// --remove-attendee-ids is subtracted and --add-attendee-ids is unioned in.
func createFollowingSeries(runtime *common.RuntimeContext, calendarID string, master, pivot *calendarEvent, pivotUnix int64) (string, map[string]interface{}, int, int, error) {
body := map[string]interface{}{
"summary": "",
"attendee_ability": firstNonEmpty(master.AttendeeAbility, "can_modify_event"),
"free_busy_status": firstNonEmpty(master.FreeBusyStatus, "busy"),
"reminders": func() interface{} {
if len(master.Reminders) == 0 {
return []map[string]int{{"minutes": 5}}
}
return master.Reminders
}(),
"color": master.Color,
}
if master.VChat != nil {
body["vchat"] = master.VChat
}
if master.Location != nil {
body["location"] = master.Location
}
if master.Visibility != "" {
body["visibility"] = master.Visibility
}
if master.Attachments != nil {
body["attachments"] = master.Attachments
}
if master.EventCheckIn != nil {
body["event_check_in"] = master.EventCheckIn
}
if runtime.Cmd.Flags().Changed("summary") {
body["summary"] = runtime.Str("summary")
} else if master.Summary != "" {
body["summary"] = master.Summary
}
if runtime.Cmd.Flags().Changed("description") {
body["description_rich"] = runtime.Str("description")
} else if master.DescriptionRich != "" {
body["description_rich"] = master.DescriptionRich
} else if master.Description != "" {
body["description_rich"] = master.Description
}
startTs, endTs, err := resolveFollowingSeriesTimes(runtime, pivot, pivotUnix)
if err != nil {
return "", nil, 0, 0, err
}
startTimeMap := map[string]string{"timestamp": startTs}
if master.StartTime != nil && master.StartTime.Timezone != "" {
startTimeMap["timezone"] = master.StartTime.Timezone
}
body["start_time"] = startTimeMap
endTimeMap := map[string]string{"timestamp": endTs}
if master.EndTime != nil && master.EndTime.Timezone != "" {
endTimeMap["timezone"] = master.EndTime.Timezone
}
body["end_time"] = endTimeMap
newRRule := ""
if runtime.Cmd.Flags().Changed("rrule") {
newRRule = strings.TrimSpace(runtime.Str("rrule"))
} else {
newRRule = strings.TrimSpace(inheritRRuleForFollowing(master.Recurrence))
}
if newRRule != "" {
body["recurrence"] = newRRule
}
data, err := runtime.CallAPITyped("POST",
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events", validate.EncodePathSegment(calendarID)),
nil, body)
if err != nil {
return "", nil, 0, 0, err
}
newEvent, _ := data["event"].(map[string]interface{})
newEventID, _ := newEvent["event_id"].(string)
if newEventID == "" {
return "", nil, 0, 0, errs.NewInternalError(errs.SubtypeInvalidResponse, "create returned empty event_id for the new following series")
}
// Attendees / rooms: inherit master's, subtract --remove, union --add.
inherited, err := fetchInheritableAttendees(runtime, calendarID, master.EventID)
if err != nil {
return newEventID, newEvent, 0, 0, withStepContext(err, "new series %s created but master attendee inheritance failed", newEventID)
}
removeIDs := map[string]struct{}{}
removedCount := 0
if removeStr := runtime.Str("remove-attendee-ids"); strings.TrimSpace(removeStr) != "" {
ids, err := parseCalendarAttendeeIDs(removeStr)
if err != nil {
return newEventID, newEvent, 0, 0, withParam(err, "--remove-attendee-ids")
}
for _, id := range ids {
removeIDs[id] = struct{}{}
}
removedCount = len(ids)
}
final := filterOutAttendees(inherited, removeIDs)
addedCount := 0
if addStr := runtime.Str("add-attendee-ids"); strings.TrimSpace(addStr) != "" {
added, err := parseAttendees(addStr, "")
if err != nil {
return newEventID, newEvent, 0, 0, withParam(err, "--add-attendee-ids")
}
final = mergeAttendees(final, added)
addedCount = len(added)
}
if len(final) > 0 {
_, err = runtime.CallAPITyped("POST",
calendarUpdateAttendeesPath(calendarID, newEventID),
map[string]interface{}{"user_id_type": "open_id"},
map[string]interface{}{"attendees": final, "need_notification": runtime.Bool("notify")})
if err != nil {
return newEventID, newEvent, addedCount, removedCount, withStepContext(err, "new series %s created but attendee sync failed", newEventID)
}
}
return newEventID, newEvent, addedCount, removedCount, nil
}
// inheritRRuleForFollowing keeps every rrule segment except COUNT. UNTIL is a
// fixed wall-clock cutoff and copies cleanly onto the new series; COUNT is an
// occurrence total anchored on the old dtstart and would be misinterpreted
// against the new pivot, so we drop it.
func inheritRRuleForFollowing(rrule string) string {
body := strings.TrimSpace(rrule)
prefix := ""
if strings.HasPrefix(body, "RRULE:") {
prefix = "RRULE:"
body = strings.TrimPrefix(body, "RRULE:")
}
var kept []string
for _, part := range strings.Split(body, ";") {
p := strings.TrimSpace(part)
if p == "" {
continue
}
if strings.HasPrefix(strings.ToUpper(p), "COUNT=") {
continue
}
kept = append(kept, p)
}
if len(kept) == 0 {
return ""
}
return prefix + strings.Join(kept, ";")
}
// fetchInheritableAttendees pages through the master's attendee list and
// projects each entry back into the create/attendees request shape
// ({type, user_id|chat_id|room_id, ...}). Third-party attendees are skipped
// because /events/{id}/attendees does not accept them.
func fetchInheritableAttendees(runtime *common.RuntimeContext, calendarID, masterID string) ([]map[string]interface{}, error) {
if strings.TrimSpace(masterID) == "" {
return nil, nil
}
out := []map[string]interface{}{}
pageToken := ""
for {
params := map[string]interface{}{"page_size": 100, "user_id_type": "open_id"}
if pageToken != "" {
params["page_token"] = pageToken
}
data, err := runtime.CallAPITyped("GET",
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s/attendees",
validate.EncodePathSegment(calendarID), validate.EncodePathSegment(masterID)),
params, nil)
if err != nil {
return nil, err
}
for _, raw := range common.GetSlice(data, "items") {
item, ok := raw.(map[string]interface{})
if !ok {
continue
}
if projected := projectInheritedAttendee(item); projected != nil {
out = append(out, projected)
}
}
hasMore, _ := data["has_more"].(bool)
pageToken, _ = data["page_token"].(string)
if !hasMore || pageToken == "" {
break
}
}
return out, nil
}
// projectInheritedAttendee reduces a fetched attendee entry to the fields the
// create/attendees endpoint accepts on a new event. Returns nil when the entry
// is not carriable (unknown type, third-party without shape guarantees, or
// missing the id field its type requires).
func projectInheritedAttendee(a map[string]interface{}) map[string]interface{} {
t, _ := a["type"].(string)
switch t {
case "user":
userID, _ := a["user_id"].(string)
if userID == "" {
return nil
}
return map[string]interface{}{"type": "user", "user_id": userID}
case "resource":
roomID, _ := a["room_id"].(string)
if roomID == "" {
return nil
}
out := map[string]interface{}{"type": "resource", "room_id": roomID}
if v, ok := a["resource_customization"]; ok && v != nil {
out["resource_customization"] = v
}
return out
case "chat":
chatID, _ := a["chat_id"].(string)
if chatID == "" {
return nil
}
return map[string]interface{}{"type": "chat", "chat_id": chatID}
case "third_party":
thirdPartyEmail, _ := a["third_party_email"].(string)
if thirdPartyEmail == "" {
return nil
}
return map[string]interface{}{"type": "third_party", "third_party_email": thirdPartyEmail}
}
return nil
}
// filterOutAttendees drops entries whose id matches a value in removeIDs. It
// only inspects the id field appropriate to each type.
func filterOutAttendees(list []map[string]interface{}, removeIDs map[string]struct{}) []map[string]interface{} {
if len(removeIDs) == 0 {
return list
}
out := make([]map[string]interface{}, 0, len(list))
for _, a := range list {
if attendeeMatchesRemoval(a, removeIDs) {
continue
}
out = append(out, a)
}
return out
}
func attendeeMatchesRemoval(a map[string]interface{}, removeIDs map[string]struct{}) bool {
for _, key := range []string{"user_id", "chat_id", "room_id"} {
if id, _ := a[key].(string); id != "" {
if _, ok := removeIDs[id]; ok {
return true
}
}
}
return false
}
// mergeAttendees appends parsed --add-attendee-ids entries (map[string]string
// as produced by parseAttendees) onto the inherited list without duplicating
// an entry already present.
func mergeAttendees(inherited []map[string]interface{}, added []map[string]string) []map[string]interface{} {
seen := map[string]struct{}{}
for _, a := range inherited {
if key := attendeeKey(a); key != "" {
seen[key] = struct{}{}
}
}
out := inherited
for _, a := range added {
entry := map[string]interface{}{}
for k, v := range a {
entry[k] = v
}
key := attendeeKey(entry)
if key == "" {
continue
}
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
out = append(out, entry)
}
return out
}
func attendeeKey(a map[string]interface{}) string {
t, _ := a["type"].(string)
for _, key := range []string{"user_id", "chat_id", "room_id"} {
if id, _ := a[key].(string); id != "" {
return t + ":" + id
}
}
return ""
}
// resolveFollowingSeriesTimes returns the (start, end) unix-seconds strings
// to use for the new series. If the caller explicitly set --start/--end, they
// take precedence; otherwise the pivot instance's own times are reused so the
// new series begins right where the caller pointed.
func resolveFollowingSeriesTimes(runtime *common.RuntimeContext, pivot *calendarEvent, pivotUnix int64) (string, string, error) {
if runtime.Cmd.Flags().Changed("start") && runtime.Cmd.Flags().Changed("end") {
startTs, err := common.ParseTime(runtime.Str("start"))
if err != nil {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
}
endTs, err := common.ParseTime(runtime.Str("end"), "end")
if err != nil {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
}
return startTs, endTs, nil
}
// Fall back to the pivot instance times when the caller only changed
// non-time fields.
if pivot != nil && pivot.StartTime != nil && pivot.EndTime != nil {
startTs := pivot.StartTime.Timestamp
endTs := pivot.EndTime.Timestamp
if startTs != "" && endTs != "" {
return startTs, endTs, nil
}
}
// Last-resort: same duration as one hour starting at pivot. The instances
// API always returns instance timestamps, so this should not happen in
// practice.
return fmt.Sprintf("%d", pivotUnix), fmt.Sprintf("%d", pivotUnix+3600), nil
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if v != "" {
return v
}
}
return ""
}