When Can We Meet? Cross-Server Busy Sync for Nextcloud

When Can We Meet? Cross-Server Busy Sync for Nextcloud

The Problem: Nextcloud's Island Calendars

Nextcloud is fantastic for self-hosted productivity. But there's a gap that frustrates anyone managing multiple instances: calendars don't talk to each other.

If you run separate Nextcloud servers for work, personal use, and a side project, you face a scheduling nightmare. Book a meeting on Server A and Server B has no idea you're busy. Double-bookings become inevitable.

Nextcloud supports CalDAV subscriptions, but these are read-only and don't integrate with free/busy lookups. Federation exists for file sharing, but calendar federation? Not really there yet.

The enterprise world solves this with Exchange or Google Workspace. But if you've chosen Nextcloud for privacy and control, those aren't options.

The Solution: A CalDAV Busy Sync Script

We built a bash script that runs on a central server and:

  1. Fetches events from each Nextcloud instance via CalDAV
  2. Extracts only the busy times (start/end), discarding event titles, descriptions, and attendees
  3. Pushes "Busy" blocks to an "External Busy" calendar on each server

The result: each Nextcloud instance shows when users on other instances are busy, without exposing any private details.

                    +---------------------------+
                    |      Script Server        |
                    |                           |
                    |  1. Fetch via CalDAV      |
                    |  2. Extract busy times    |
                    |  3. Push to all servers   |
                    +---------------------------+
                         |       |       |
                         v       v       v
                    +-------+-------+-------+
                    |   A   |   B   |   C   |
                    +-------+-------+-------+

              Each server sees when others are busy

How It Works

CalDAV: The Universal Calendar Protocol

CalDAV (RFC 4791) is the standard protocol for calendar access. Every Nextcloud instance speaks it natively at remote.php/dav/calendars/. The script uses standard CalDAV operations:

  • REPORT with calendar-query to fetch events in a time range
  • MKCALENDAR to create the "External Busy" calendar
  • PUT to create/update busy block events

Privacy by Design

Why not just share the full calendar? Because you might not want cleartext meeting titles and descriptions sitting on a server you don't control, or one where other admins have access to the database.

The script deliberately strips all event details:

BEGIN:VEVENT
UID:busy-server-a-abc123
DTSTART:20250115T090000Z
DTEND:20250115T100000Z
SUMMARY:Busy (server-a)
TRANSP:OPAQUE
END:VEVENT

That's it. No meeting titles, no attendees, no descriptions. Just "this person is busy from 9-10am."

Sync Window

By default, the script syncs:

  • 7 days in the past
  • 90 days in the future

Events outside this window are ignored. Old busy blocks naturally age out, so no cleanup is needed.

Transparent Events Skipped

Events marked as TRANSP:TRANSPARENT (like "Out of Office" reminders that don't block time) are automatically skipped. Only events that actually block time are synced.

Setup

Prerequisites

  • A server with bash, curl, and cron (or systemd timers)
  • Network access to all Nextcloud instances
  • An app password for each Nextcloud user (Settings → Security → Devices & sessions)

1. Create the Config File

# busy-sync.conf
# IMPORTANT: chmod 600 busy-sync.conf

[server-a]
url = https://cloud.example-a.com
user = alice
password = xxxxx-xxxxx-xxxxx-xxxxx-xxxxx
calendar = personal

[server-b]
url = https://cloud.example-b.com
user = bob
password = xxxxx-xxxxx-xxxxx-xxxxx-xxxxx
calendar = personal

[server-c]
url = https://cloud.example-c.com
user = charlie
password = xxxxx-xxxxx-xxxxx-xxxxx-xxxxx
calendar = personal

2. Test Connections

./nextcloud-busy-sync.sh --test --verbose

3. Run a Dry Run

./nextcloud-busy-sync.sh --dry-run --verbose

4. Run for Real

./nextcloud-busy-sync.sh

5. Set Up Automated Sync

With systemd (recommended):

# /etc/systemd/system/busy-sync.service
[Unit]
Description=Nextcloud Busy Calendar Sync
After=network-online.target

[Service]
Type=oneshot
User=busy-sync
ExecStart=/opt/busy-sync/nextcloud-busy-sync.sh

# /etc/systemd/system/busy-sync.timer
[Unit]
Description=Run Nextcloud Busy Sync every 15 minutes

[Timer]
OnBootSec=2min
OnUnitActiveSec=15min
Persistent=true

[Install]
WantedBy=timers.target

Or with cron:

*/15 * * * * /opt/busy-sync/nextcloud-busy-sync.sh >> /var/log/busy-sync.log 2>&1

Limitations

  • One-way privacy: The script syncs busy times, but if someone has access to the "External Busy" calendar, they can see which server a busy block came from (via the summary "Busy (server-a)"). You can modify the script to use a generic "Busy" summary if needed.

  • No real-time sync: The script runs on a schedule (e.g., every 15 minutes). For immediate sync, you'd need webhooks or a persistent daemon.

  • Recurring events: The script fetches expanded instances within the sync window. It doesn't handle recurrence rules specially, so each occurrence becomes a separate busy block.

Conclusion

Cross-server calendar sync is a gap in the Nextcloud ecosystem. Until native federation supports it, this script provides a privacy-respecting bridge. It's simple, uses standard protocols, and doesn't require any Nextcloud modifications.

The code is open source. Fork it, improve it, adapt it to your needs.


Appendix A: Example Configuration

# Nextcloud Busy Sync Configuration
# Copy this to busy-sync.conf and fill in your values
# IMPORTANT: chmod 600 busy-sync.conf

# Server A - First Nextcloud instance
[server-a]
url = https://cloud.example-a.com
user = alice
password = xxxxx-xxxxx-xxxxx-xxxxx-xxxxx
calendar = personal

# Server B - Second Nextcloud instance
[server-b]
url = https://cloud.example-b.com
user = bob
password = xxxxx-xxxxx-xxxxx-xxxxx-xxxxx
calendar = personal

# Server C - Third Nextcloud instance
[server-c]
url = https://cloud.example-c.com
user = charlie
password = xxxxx-xxxxx-xxxxx-xxxxx-xxxxx
calendar = personal

# Notes:
# - url: Base URL of your Nextcloud instance (no trailing slash)
# - user: Username of the account with calendar access
# - password: App password (Settings -> Security -> Devices & sessions)
# - calendar: The calendar ID to read from (usually 'personal' for default)
#
# To find your calendar ID:
#   1. Open Nextcloud calendar
#   2. Click the three dots next to your calendar
#   3. Click "Copy private link"
#   4. The calendar ID is the last part of the URL (e.g., personal, work, etc.)

Appendix B: The Script

#!/bin/bash
#
# nextcloud-busy-sync.sh
# Syncs busy times across multiple Nextcloud instances via CalDAV
#
# Each server gets an "External Busy" calendar populated with busy blocks
# from all other servers (without exposing event details).
#

set -uo pipefail
# Note: -e removed to prevent silent failures; errors handled explicitly

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_FILE="${CONFIG_FILE:-$SCRIPT_DIR/busy-sync.conf}"
LOG_FILE="${LOG_FILE:-/tmp/nextcloud-busy-sync.log}"
DRY_RUN="${DRY_RUN:-false}"
VERBOSE="${VERBOSE:-false}"

# Lookback/lookahead for sync window (in days)
SYNC_DAYS_PAST="${SYNC_DAYS_PAST:-7}"
SYNC_DAYS_FUTURE="${SYNC_DAYS_FUTURE:-90}"

# Name of the calendar to create/update on each server
BUSY_CALENDAR_NAME="${BUSY_CALENDAR_NAME:-external-busy}"
BUSY_CALENDAR_DISPLAY="${BUSY_CALENDAR_DISPLAY:-External Busy}"

# Temp directory for working files
TMPDIR="${TMPDIR:-/tmp}"
WORK_DIR=""

#------------------------------------------------------------------------------
# Logging
#------------------------------------------------------------------------------
log() {
    local level="$1"
    shift
    local msg="$*"
    local timestamp
    timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
    echo "[$timestamp] [$level] $msg" | tee -a "$LOG_FILE"
}

log_info()  { log "INFO" "$@"; }
log_warn()  { log "WARN" "$@"; }
log_error() { log "ERROR" "$@"; }
log_debug() { [[ "$VERBOSE" == "true" ]] && log "DEBUG" "$@" || true; }

#------------------------------------------------------------------------------
# Cleanup
#------------------------------------------------------------------------------
cleanup() {
    if [[ -n "${WORK_DIR:-}" && -d "$WORK_DIR" ]]; then
        rm -rf "$WORK_DIR"
    fi
}
trap cleanup EXIT

#------------------------------------------------------------------------------
# Config parsing
#------------------------------------------------------------------------------
declare -A SERVERS
declare -a SERVER_NAMES

parse_config() {
    if [[ ! -f "$CONFIG_FILE" ]]; then
        log_error "Config file not found: $CONFIG_FILE"
        exit 1
    fi

    # Check config file permissions (should be 600 or 400)
    local perms
    perms=$(stat -c "%a" "$CONFIG_FILE" 2>/dev/null || stat -f "%Lp" "$CONFIG_FILE" 2>/dev/null)
    if [[ "$perms" != "600" && "$perms" != "400" ]]; then
        log_warn "Config file permissions are $perms, recommend 600"
    fi

    local current_server=""
    while IFS= read -r line || [[ -n "$line" ]]; do
        # Skip comments and empty lines
        [[ "$line" =~ ^[[:space:]]*# ]] && continue
        [[ -z "${line// }" ]] && continue

        # Section header [server-name]
        if [[ "$line" =~ ^\[([a-zA-Z0-9_-]+)\]$ ]]; then
            current_server="${BASH_REMATCH[1]}"
            SERVER_NAMES+=("$current_server")
            continue
        fi

        # Key=value pairs
        if [[ -n "$current_server" && "$line" =~ ^([a-zA-Z_]+)[[:space:]]*=[[:space:]]*(.+)$ ]]; then
            local key="${BASH_REMATCH[1]}"
            local value="${BASH_REMATCH[2]}"
            # Trim quotes if present
            value="${value%\"}"
            value="${value#\"}"
            value="${value%\'}"
            value="${value#\'}"
            SERVERS["${current_server}_${key}"]="$value"
        fi
    done < "$CONFIG_FILE"

    # Validate we have at least 2 servers
    if [[ ${#SERVER_NAMES[@]} -lt 2 ]]; then
        log_error "Need at least 2 servers configured, found ${#SERVER_NAMES[@]}"
        exit 1
    fi

    # Validate each server has required fields
    for server in "${SERVER_NAMES[@]}"; do
        for field in url user password calendar; do
            if [[ -z "${SERVERS[${server}_${field}]:-}" ]]; then
                log_error "Server [$server] missing required field: $field"
                exit 1
            fi
        done
        log_debug "Loaded server: $server -> ${SERVERS[${server}_url]}"
    done

    log_info "Loaded ${#SERVER_NAMES[@]} servers from config"
}

#------------------------------------------------------------------------------
# CalDAV helpers
#------------------------------------------------------------------------------
caldav_request() {
    local server="$1"
    local method="$2"
    local path="$3"
    local data="${4:-}"
    local extra_headers="${5:-}"

    local url="${SERVERS[${server}_url]}"
    local user="${SERVERS[${server}_user]}"
    local pass="${SERVERS[${server}_password]}"

    local full_url="${url%/}/${path#/}"
    local curl_args=(
        -s
        -w "\n%{http_code}"
        -X "$method"
        -u "${user}:${pass}"
        -H "Content-Type: application/xml; charset=utf-8"
    )

    # Add Depth header for PROPFIND and REPORT (required by CalDAV RFC 4791)
    if [[ "$method" == "PROPFIND" || "$method" == "REPORT" ]]; then
        curl_args+=(-H "Depth: 1")
    fi

    # Add any extra headers
    if [[ -n "$extra_headers" ]]; then
        curl_args+=(-H "$extra_headers")
    fi

    if [[ -n "$data" ]]; then
        curl_args+=(--data-binary "$data")
    fi

    log_debug "CalDAV $method: $full_url"

    local response http_code
    response=$(curl "${curl_args[@]}" "$full_url")
    http_code=$(echo "$response" | tail -n1)
    response=$(echo "$response" | sed '$d')

    # Check for HTTP errors
    if [[ "$http_code" -ge 400 ]]; then
        log_debug "HTTP $http_code response from $full_url"
        echo "HTTP_ERROR:$http_code"
        return 1
    fi

    echo "$response"
}

#------------------------------------------------------------------------------
# Fetch events from a calendar within the sync window
#------------------------------------------------------------------------------
fetch_calendar_events() {
    local server="$1"
    local calendar="${SERVERS[${server}_calendar]}"
    local user="${SERVERS[${server}_user]}"

    # Build calendar path (standard Nextcloud CalDAV path)
    local cal_path="remote.php/dav/calendars/${user}/${calendar}/"

    # Date range for REPORT query (cross-platform date handling)
    local start_date end_date
    if date --version >/dev/null 2>&1; then
        # GNU date (Linux)
        start_date=$(date -d "-${SYNC_DAYS_PAST} days" '+%Y%m%dT000000Z')
        end_date=$(date -d "+${SYNC_DAYS_FUTURE} days" '+%Y%m%dT235959Z')
    else
        # BSD date (macOS)
        start_date=$(date -v-"${SYNC_DAYS_PAST}"d '+%Y%m%dT000000Z')
        end_date=$(date -v+"${SYNC_DAYS_FUTURE}"d '+%Y%m%dT235959Z')
    fi

    log_debug "Time range: $start_date to $end_date"

    # REPORT request to get events in time range
    # Using C: prefix for CalDAV namespace (standard)
    local report_xml="<?xml version=\"1.0\" encoding=\"utf-8\" ?>
<C:calendar-query xmlns:D=\"DAV:\" xmlns:C=\"urn:ietf:params:xml:ns:caldav\">
  <D:prop>
    <D:getetag/>
    <C:calendar-data/>
  </D:prop>
  <C:filter>
    <C:comp-filter name=\"VCALENDAR\">
      <C:comp-filter name=\"VEVENT\">
        <C:time-range start=\"${start_date}\" end=\"${end_date}\"/>
      </C:comp-filter>
    </C:comp-filter>
  </C:filter>
</C:calendar-query>"

    caldav_request "$server" "REPORT" "$cal_path" "$report_xml"
}

#------------------------------------------------------------------------------
# Extract calendar-data from multistatus XML response
# Handles various namespace prefixes (cal:, C:, caldav:, or no prefix)
#------------------------------------------------------------------------------
extract_calendar_data() {
    local xml_response="$1"
    local output_file="$2"

    # Use awk to extract calendar-data content, handling any namespace prefix
    # Pattern matches: <X:calendar-data> or <calendar-data> where X is any prefix
    echo "$xml_response" | awk '
    BEGIN { capture = 0; data = "" }
    {
        line = $0

        # Check for opening tag with any namespace prefix or none
        # Matches: <cal:calendar-data>, <C:calendar-data>, <calendar-data>, etc.
        if (match(line, /<([a-zA-Z0-9]*:)?calendar-data[^>]*>/)) {
            capture = 1
            # Remove everything up to and including opening tag
            sub(/.*<([a-zA-Z0-9]*:)?calendar-data[^>]*>/, "", line)
        }

        # Check for closing tag (may be on same line as opening)
        if (capture && match(line, /<\/([a-zA-Z0-9]*:)?calendar-data>/)) {
            # Get content before closing tag
            sub(/<\/([a-zA-Z0-9]*:)?calendar-data>.*/, "", line)
            data = data line
            # Output accumulated data and reset
            if (length(data) > 0) print data
            data = ""
            capture = 0
        } else if (capture) {
            data = data line "\n"
        }
    }
    ' > "$output_file"

    # Decode XML entities
    sed -i.bak 's/&lt;/</g; s/&gt;/>/g; s/&amp;/\&/g; s/&quot;/"/g; s/&apos;/'"'"'/g' "$output_file" 2>/dev/null || \
    sed -i '' 's/&lt;/</g; s/&gt;/>/g; s/&amp;/\&/g; s/&quot;/"/g; s/&apos;/'"'"'/g' "$output_file"
    rm -f "${output_file}.bak" 2>/dev/null || true
}

#------------------------------------------------------------------------------
# Extract busy blocks from ICS data (strips details, keeps only times)
#------------------------------------------------------------------------------
extract_busy_blocks() {
    local ics_file="$1"
    local source_server="$2"

    # Use awk to parse VEVENT blocks and extract DTSTART/DTEND/UID
    # Handles events with DTEND or DURATION, all-day events, etc.
    awk -v source="$source_server" '
    BEGIN {
        in_event = 0
        dtstart = ""
        dtend = ""
        duration = ""
        uid = ""
        is_transparent = 0
    }
    /^BEGIN:VEVENT/ {
        in_event = 1
        dtstart = ""
        dtend = ""
        duration = ""
        uid = ""
        is_transparent = 0
    }
    /^END:VEVENT/ {
        if (in_event && dtstart != "" && !is_transparent) {
            # If no DTEND but has DURATION, we still output (will use DTSTART as marker)
            # If no DTEND and no DURATION for all-day event, skip (cant determine end)
            if (dtend != "" || duration != "") {
                # Use DTEND if available, otherwise mark as duration-based
                end_val = (dtend != "") ? dtend : "DURATION:" duration
                print source "|" uid "|" dtstart "|" end_val
            }
        }
        in_event = 0
    }
    in_event && /^DTSTART/ {
        gsub(/^DTSTART[^:]*:/, "")
        gsub(/\r/, "")
        dtstart = $0
    }
    in_event && /^DTEND/ {
        gsub(/^DTEND[^:]*:/, "")
        gsub(/\r/, "")
        dtend = $0
    }
    in_event && /^DURATION:/ {
        gsub(/^DURATION:/, "")
        gsub(/\r/, "")
        duration = $0
    }
    in_event && /^UID:/ {
        gsub(/^UID:/, "")
        gsub(/\r/, "")
        uid = $0
    }
    # Skip transparent (free) events
    in_event && /^TRANSP:TRANSPARENT/ {
        is_transparent = 1
    }
    ' "$ics_file"
}

#------------------------------------------------------------------------------
# Ensure the External Busy calendar exists on a server
#------------------------------------------------------------------------------
ensure_busy_calendar() {
    local server="$1"
    local user="${SERVERS[${server}_user]}"
    local cal_path="remote.php/dav/calendars/${user}/${BUSY_CALENDAR_NAME}"

    log_debug "Checking if busy calendar exists on $server"

    # Try PROPFIND to see if calendar exists
    # We check for <cal:calendar> in resourcetype to confirm it's a calendar
    local propfind_xml='<?xml version="1.0" encoding="utf-8" ?>
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
  <D:prop>
    <D:resourcetype/>
  </D:prop>
</D:propfind>'

    local response
    response=$(caldav_request "$server" "PROPFIND" "$cal_path" "$propfind_xml" 2>&1) || true

    # Check if calendar exists:
    # - HTTP 404 means not found
    # - If response doesn't contain "calendar" in resourcetype, it doesn't exist
    # - Nextcloud returns 207 with error message for non-existent resources
    local calendar_exists=false
    if [[ "$response" != HTTP_ERROR:404* ]] && \
       [[ "$response" != *"Not Found"* ]] && \
       [[ "$response" != *"does not exist"* ]] && \
       echo "$response" | grep -qiE "<[^>]*:calendar\s*/>" ; then
        calendar_exists=true
    fi

    if [[ "$calendar_exists" == "false" ]]; then
        log_info "Creating busy calendar on $server"

        if [[ "$DRY_RUN" == "true" ]]; then
            log_info "[DRY RUN] Would create calendar: $cal_path"
            return 0
        fi

        # MKCALENDAR request (Nextcloud supports this without XML body for basic creation)
        # But we include properties for display name and color
        local mkcal_xml="<?xml version=\"1.0\" encoding=\"utf-8\" ?>
<C:mkcalendar xmlns:D=\"DAV:\" xmlns:C=\"urn:ietf:params:xml:ns:caldav\" xmlns:ICAL=\" http://apple.com/ns/ical/\">
  <D:set>
    <D:prop>
      <D:displayname>${BUSY_CALENDAR_DISPLAY}</D:displayname>
      <ICAL:calendar-color>#808080</ICAL:calendar-color>
      <C:calendar-description>Auto-synced busy times from other calendars</C:calendar-description>
    </D:prop>
  </D:set>
</C:mkcalendar>"

        local create_response
        create_response=$(caldav_request "$server" "MKCALENDAR" "$cal_path" "$mkcal_xml" 2>&1) || true

        if [[ "$create_response" == HTTP_ERROR* ]]; then
            log_error "Failed to create busy calendar on $server: $create_response"
            return 1
        fi
        log_info "Created busy calendar on $server"
    else
        log_debug "Busy calendar already exists on $server"
    fi
}

#------------------------------------------------------------------------------
# Push a busy block to a server's External Busy calendar
#------------------------------------------------------------------------------
push_busy_event() {
    local target_server="$1"
    local source_server="$2"
    local uid="$3"
    local dtstart="$4"
    local dtend="$5"

    local user="${SERVERS[${target_server}_user]}"

    # Sanitize UID for use in filename (remove special chars)
    local safe_uid
    safe_uid=$(echo "$uid" | sed 's/[^a-zA-Z0-9_-]/_/g' | cut -c1-64)

    # Create a unique UID for this busy block
    local busy_uid="busy-${source_server}-${safe_uid}"
    local ics_file="${busy_uid}.ics"
    local event_path="remote.php/dav/calendars/${user}/${BUSY_CALENDAR_NAME}/${ics_file}"

    # Handle DURATION-based events
    local dtend_line
    if [[ "$dtend" == DURATION:* ]]; then
        local duration="${dtend#DURATION:}"
        dtend_line="DURATION:${duration}"
    else
        dtend_line="DTEND:${dtend}"
    fi

    # Build minimal ICS with just BUSY status
    # DTSTAMP must be in UTC
    local dtstamp
    dtstamp=$(date -u '+%Y%m%dT%H%M%SZ')

    local ics_data
    ics_data="BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Nextcloud Busy Sync//EN
BEGIN:VEVENT
UID:${busy_uid}
DTSTAMP:${dtstamp}
DTSTART:${dtstart}
${dtend_line}
SUMMARY:Busy (${source_server})
TRANSP:OPAQUE
X-BUSY-SOURCE:${source_server}
END:VEVENT
END:VCALENDAR"

    log_debug "Pushing busy event to $target_server: $dtstart - $dtend"

    if [[ "$DRY_RUN" == "true" ]]; then
        log_info "[DRY RUN] Would push: $busy_uid to $target_server"
        return 0
    fi

    # Use PUT to create or update the event
    # Simple PUT without conditional headers - CalDAV servers handle create/update automatically
    local url="${SERVERS[${target_server}_url]}"
    local pass="${SERVERS[${target_server}_password]}"
    local full_url="${url%/}/${event_path}"

    local response http_code
    response=$(curl -s -w "\n%{http_code}" -X PUT \
        -u "${user}:${pass}" \
        -H "Content-Type: text/calendar; charset=utf-8" \
        --data-binary "$ics_data" \
        "$full_url")
    http_code=$(echo "$response" | tail -n1)

    # 201 = created, 204 = updated, both are success
    if [[ "$http_code" == "201" || "$http_code" == "204" ]]; then
        return 0
    fi

    # Log failure with details
    log_warn "Failed to push event to $target_server (HTTP $http_code): $busy_uid"
    return 1
}

#------------------------------------------------------------------------------
# Main sync logic
#------------------------------------------------------------------------------
sync_all() {
    log_info "Starting busy sync for ${#SERVER_NAMES[@]} servers"

    # Create working directory
    WORK_DIR=$(mktemp -d "${TMPDIR}/busy-sync.XXXXXX")
    log_debug "Working directory: $WORK_DIR"

    # Ensure all servers have the busy calendar
    for server in "${SERVER_NAMES[@]}"; do
        ensure_busy_calendar "$server" || {
            log_error "Failed to ensure busy calendar on $server, skipping"
            continue
        }
    done

    # Collect busy blocks from all servers
    declare -A ALL_BUSY_BLOCKS

    for server in "${SERVER_NAMES[@]}"; do
        log_info "Fetching events from $server"

        local response_file="${WORK_DIR}/${server}_response.xml"
        local ics_file="${WORK_DIR}/${server}_events.ics"

        local calendar="${SERVERS[${server}_calendar]}"
        local user="${SERVERS[${server}_user]}"
        log_debug "Calendar path: remote.php/dav/calendars/${user}/${calendar}/"

        local response
        response=$(fetch_calendar_events "$server") || {
            log_error "Failed to fetch from $server (curl error)"
            log_debug "Check: URL=${SERVERS[${server}_url]}, user=${user}, calendar=${calendar}"
            continue
        }

        if [[ "$response" == HTTP_ERROR* ]]; then
            local http_code="${response#HTTP_ERROR:}"
            log_error "HTTP $http_code error fetching from $server"
            case "$http_code" in
                401) log_error "  -> Authentication failed. Check username/password (use app password)" ;;
                403) log_error "  -> Forbidden. User may not have calendar access" ;;
                404) log_error "  -> Calendar '${calendar}' not found for user '${user}'" ;;
                *) log_error "  -> Check URL and credentials" ;;
            esac
            continue
        fi

        # Save response for debugging
        echo "$response" > "$response_file"

        # Extract calendar-data from multistatus response
        extract_calendar_data "$response" "$ics_file"

        if [[ -s "$ics_file" ]]; then
            local blocks
            blocks=$(extract_busy_blocks "$ics_file" "$server")
            ALL_BUSY_BLOCKS["$server"]="$blocks"

            local count=0
            if [[ -n "$blocks" ]]; then
                count=$(echo "$blocks" | grep -c '^' || echo 0)
            fi
            log_info "Found $count events on $server"
            log_debug "Events from $server:"
            [[ "$VERBOSE" == "true" && -n "$blocks" ]] && echo "$blocks" | head -5
        else
            log_warn "No calendar data found for $server"
        fi
    done

    # Push busy blocks to each server (from other servers only)
    for target in "${SERVER_NAMES[@]}"; do
        log_info "Syncing busy times to $target"

        # Using upsert instead of delete-then-create
        # Stale events naturally expire when they fall outside the sync window

        local pushed=0
        local failed=0
        for source in "${SERVER_NAMES[@]}"; do
            # Don't push a server's own busy times back to itself
            [[ "$source" == "$target" ]] && continue

            local blocks="${ALL_BUSY_BLOCKS[$source]:-}"
            [[ -z "$blocks" ]] && continue

            while IFS='|' read -r _src uid dtstart dtend; do
                [[ -z "$dtstart" ]] && continue
                if push_busy_event "$target" "$source" "$uid" "$dtstart" "$dtend"; then
                    pushed=$((pushed + 1))
                else
                    failed=$((failed + 1))
                fi
            done <<< "$blocks"
        done

        log_info "Pushed $pushed busy blocks to $target ($failed failed)"
    done

    log_info "Sync complete"
}

#------------------------------------------------------------------------------
# Test connectivity to all servers
#------------------------------------------------------------------------------
test_connections() {
    log_info "Testing connections to all servers..."
    local all_ok=true

    for server in "${SERVER_NAMES[@]}"; do
        local user="${SERVERS[${server}_user]}"
        local test_path="remote.php/dav/calendars/${user}/"

        log_info "Testing $server..."
        local response
        response=$(caldav_request "$server" "PROPFIND" "$test_path" '<?xml version="1.0"?><D:propfind xmlns:D="DAV:"><D:prop><D:resourcetype/></D:prop></D:propfind>' 2>&1) || true

        if [[ "$response" == HTTP_ERROR* ]]; then
            log_error "  FAILED: $response"
            all_ok=false
        elif echo "$response" | grep -q "calendar"; then
            log_info "  OK: Connected successfully"
        else
            log_warn "  WARNING: Unexpected response (may still work)"
            log_debug "Response: ${response:0:200}..."
        fi
    done

    if [[ "$all_ok" == "true" ]]; then
        log_info "All connections successful"
    else
        log_error "Some connections failed"
        return 1
    fi
}

#------------------------------------------------------------------------------
# Usage
#------------------------------------------------------------------------------
usage() {
    cat <<EOF
Usage: $(basename "$0") [OPTIONS]

Sync busy times across Nextcloud instances via CalDAV.

Options:
  -c, --config FILE     Config file path (default: ./busy-sync.conf)
  -n, --dry-run         Show what would be done without making changes
  -t, --test            Test connections to all servers and exit
  -v, --verbose         Enable verbose logging
  -h, --help            Show this help

Environment variables:
  CONFIG_FILE           Path to config file
  LOG_FILE              Path to log file (default: /tmp/nextcloud-busy-sync.log)
  SYNC_DAYS_PAST        Days in the past to sync (default: 7)
  SYNC_DAYS_FUTURE      Days in the future to sync (default: 90)
  BUSY_CALENDAR_NAME    Name of busy calendar (default: external-busy)
  DRY_RUN               Set to 'true' for dry run mode
  VERBOSE               Set to 'true' for verbose output

Example config file (busy-sync.conf):
  [server-a]
  url = https://cloud.example-a.com
  user = alice
  password = app-password-here
  calendar = personal

  [server-b]
  url = https://cloud.example-b.com
  user = bob
  password = app-password-here
  calendar = personal

EOF
    exit 0
}

#------------------------------------------------------------------------------
# Main
#------------------------------------------------------------------------------
main() {
    local test_mode=false

    # Parse command line arguments
    while [[ $# -gt 0 ]]; do
        case "$1" in
            -c|--config)
                CONFIG_FILE="$2"
                shift 2
                ;;
            -n|--dry-run)
                DRY_RUN="true"
                shift
                ;;
            -t|--test)
                test_mode=true
                shift
                ;;
            -v|--verbose)
                VERBOSE="true"
                shift
                ;;
            -h|--help)
                usage
                ;;
            *)
                log_error "Unknown option: $1"
                usage
                ;;
        esac
    done

    log_info "Nextcloud Busy Sync starting"
    [[ "$DRY_RUN" == "true" ]] && log_info "DRY RUN mode enabled"

    parse_config

    if [[ "$test_mode" == "true" ]]; then
        test_connections
        exit $?
    fi

    sync_all
}

main "$@"