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:
- Fetches events from each Nextcloud instance via CalDAV
- Extracts only the busy times (start/end), discarding event titles, descriptions, and attendees
- 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 busyHow 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-queryto 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:VEVENTThat'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 = personal2. Test Connections
./nextcloud-busy-sync.sh --test --verbose3. Run a Dry Run
./nextcloud-busy-sync.sh --dry-run --verbose4. Run for Real
./nextcloud-busy-sync.sh5. 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.targetOr with cron:
*/15 * * * * /opt/busy-sync/nextcloud-busy-sync.sh >> /var/log/busy-sync.log 2>&1Limitations
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/</</g; s/>/>/g; s/&/\&/g; s/"/"/g; s/'/'"'"'/g' "$output_file" 2>/dev/null || \
sed -i '' 's/</</g; s/>/>/g; s/&/\&/g; s/"/"/g; s/'/'"'"'/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 "$@"