Bidirectional Calendar Synchronization for Frappe/ERPNext and CalDAV Servers
PibiCal enables seamless, real-time synchronization of calendar events between your Frappe/ERPNext instance and CalDAV-compatible servers (NextCloud, ownCloud, etc.). It supports bidirectional sync, recurring events, timezone management, and more.
- Features
- Version Compatibility
- Requirements
- Installation
- Configuration
- Usage
- Architecture
- Code Structure
- API Reference
- Performance
- Troubleshooting
- Development
- Recent Updates
- Contributing
- License
- β
Bidirectional Sync - Changes in Frappe/ERPNext
βοΈ CalDAV servers sync automatically - β Timezone Support - Events properly converted between user timezones and UTC
- β Recurring Events - Weekly, monthly, yearly patterns with end dates
- β Event Status - Open, Completed, Cancelled states synchronized
- β All-Day Events - Properly handled as date-only events
- β Multiple Calendars - Support for multiple CalDAV calendars per user
- β Event Invitations - Send calendar invitations with .ics attachments
| Feature | Frappe β CalDAV | CalDAV β Frappe |
|---|---|---|
| Speed | Instant | Every 3 minutes |
| Event Creation | β Supported | β Supported |
| Event Updates | β Supported | β Supported |
| Event Deletion | β Supported | |
| Recurring Events | β Supported | β Supported |
| Timezone Handling | β Automatic | β Automatic |
- β Private Events: Only "Public" events are synchronized
- β Attachments: File attachments are not synced
β οΈ Deletion Sync: CalDAV β Frappe deletions require manual cleanupβ οΈ Participants: Frappe β CalDAV only (disabled by default)
| Frappe Version | PibiCal Branch | Status | Notes |
|---|---|---|---|
| v15 | develop |
β Active Development | Recommended for new installations |
| v13 | version-13 |
β Stable | Production ready |
| v12 | version-12 |
No longer maintained |
Current Version: 2.0 (December 2025)
- Frappe/ERPNext: v15 (for this branch) or v13 (version-13 branch)
- CalDAV Server: NextCloud, ownCloud, or any CalDAV-compatible server
- SSL/TLS: Must be enabled (wildcard certificates NOT supported)
- Python: 3.10+ (included with Frappe)
frappe >= 15.0.0
caldav >= 0.9.0
icalendar >= 4.0.0These are automatically installed via requirements.txt.
- CalDAV Endpoint: Valid CalDAV server URL
- Authentication: Username and password/app-specific token
- Permissions: Read/write access to calendars
- Network: Stable connection between Frappe and CalDAV server
# Navigate to your frappe-bench directory
cd ~/frappe-bench
# Download the app (Frappe v15)
bench get-app pibical https://github.com/pibico/pibical.git --branch develop
# Install on your site
bench --site your-site-name install-app pibical
# Restart bench to apply changes
bench restartbench get-app pibical https://github.com/pibico/pibical.git --branch version-13
bench --site your-site-name install-app pibical
bench restart# Update the app
cd ~/frappe-bench
bench update --apps pibical --no-backup
# If you encounter dependency issues
bench update --requirements
# Restart
bench restartbench --site your-site-name uninstall-app pibical
bench remove-app pibicalEach user must configure their CalDAV credentials:
- Navigate: User List β Select User β Edit
- Scroll to: CalDAV Credentials section
- Fill in:
| Field | Description | Example |
|---|---|---|
| CalDAV URL | CalDAV server endpoint | https://cloud.example.com/remote.php/dav/principals/ |
| CalDAV Username | Your CalDAV username | john.doe or john.doe@example.com |
| CalDAV Token | Password or app-specific token | your-secure-token |
NextCloud Tip: Generate app-specific passwords from Settings β Security β Devices & Sessions
When creating or editing events:
- Set Event Type = "Public" (required)
- Check β "Sync with CalDAV"
- Select your calendar from "CalDAV ID Calendar" dropdown
- Fill in event details (subject, date, time, etc.)
- Save - Event syncs immediately to CalDAV
# Check scheduler status
cd ~/frappe-bench
bench --site your-site-name doctor
# Should show:
# β Scheduler Active: YesDefault: Every 3 minutes
To Change: Edit pibical/hooks.py
scheduler_events = {
"cron": {
"*/3 * * * *": [ # Modify this cron expression
"pibical.pibical.custom.sync_outside_caldav"
]
}
}Options:
*/1 * * * *- Every 1 minute (high load)*/5 * * * *- Every 5 minutes (balanced)*/10 * * * *- Every 10 minutes (low load)
After changing: bench restart
Default: Yesterday to +30 days
To Change: Edit pibical/pibical/custom.py (line ~627)
sel_events = c.date_search(
datetime.now().date() - timedelta(days=1), # Start
datetime.now().date() + timedelta(days=30) # End
)Recommendations:
- Small calendars (<100 events): -7 to +60 days
- Medium calendars (100-500 events): -1 to +30 days (default)
- Large calendars (>500 events): 0 to +14 days
In Frappe UI:
- Navigate to Event β New Event
- Fill in:
- Subject: "Team Meeting"
- Event Type: "Public" (required)
- Sync with CalDAV: β Checked
- CalDAV ID Calendar: Select your calendar
- Starts On / Ends On: Set date/time
- Description: Optional
- Save
Expected: Message "Event created on CalDAV server" Result: Event appears in NextCloud within 30 seconds
Actions:
- UID Generated:
frappe[hash]@pibico.es - Immediate sync to CalDAV
- Fields set:
event_uid,caldav_id_url,event_stamp
Message: "Event created on CalDAV server"
Actions:
- Background sync detects new event (every 3 minutes)
- UID preserved from CalDAV (no "frappe" prefix)
- Event Type set to "Public" in Frappe
- Timezone converted to user's local time
Result: Event appears in Frappe within 3 minutes (silent sync)
Actions:
- If calendar changed: Deletes from old, creates in new
- Uses smart update mechanism (no_create=True)
- Original UID maintained
Message: "Event updated on CalDAV server"
Actions:
- Timestamp comparison (Β±1 second tolerance)
- Updates: subject, times, description, status, recurrence
- Frappe-specific fields preserved
Result: Event updated in Frappe within 3 minutes (silent sync)
Actions:
- Immediate deletion from CalDAV
- Three methods attempted (URL, UID search, date scan)
Messages:
- β "Deleted Event in CalDav Calendar [name]"
β οΈ "Event not found in CalDAV calendar"
Current Limitation:
- Open event in Frappe
- Add participants (Contacts with email addresses)
- Save event
- Click "Send Invitations" button
- Select recipients
- Click Send
Recipients receive email with .ics attachment they can import to their calendar.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β BIDIRECTIONAL SYNC β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β ERPNext/Frappe ββββββββββββββββββββ CalDAV Server β
β β
β ββββββββββββββββ ββββββββββββββββββββ β
β β Event.save() β β CalDAV Server β β
β β βββββββββββββββββ (NextCloud) β β
β β before_save β Instant β β β
β β hook β β PUT/POST event β β
β ββββββββββββββββ ββββββββββββββββββββ β
β β β β
β β β β
β β Flag: ignore_sync β β
β β β β
β ββββββββββββββββ ββββββββββββββββββββ β
β β Background β β CalDAV Server β β
β β Job ββββββββββββββββ β β
β β (every 3min) β Poll events β GET events β β
β β β β (date range) β β
β ββββββββββββββββ ββββββββββββββββββββ β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- User saves Event in Frappe UI
before_savehook triggerssync_caldav_event_by_user()- Check flag: If
ignore_caldav_syncset, skip (prevents loops) - Validate: CalDAV credentials exist
- Build iCalendar: Convert Frappe Event β iCal format
- Sync:
- New event:
c.save_event(ical_data) - Update:
c.save_event(ical_data, no_create=True)
- New event:
- Success: Show message to user
- Cron job runs
sync_outside_caldav()every 3 minutes - Get users with CalDAV credentials
- For each user:
- Connect to CalDAV server
- For each calendar:
- Fetch events (yesterday to +30 days)
- For each event:
- Check if already processed (dedupe)
- Check if exists in Frappe by UID
- If exists: Compare timestamps, update if modified
- If new: Create in Frappe
- Set flag:
ignore_caldav_sync = True(prevents loop)
- Commit: Save all changes
Problem: Sync from CalDAV would trigger before_save hook, syncing back to CalDAV (infinite loop)
Solution: Document flags
# When syncing FROM CalDAV:
event.flags.ignore_caldav_sync = True
event.save() # Hook checks this flag and skips
# In sync hook:
if doc.flags.get('ignore_caldav_sync'):
return # Skip sync back to CalDAVFlags are:
- In-memory only (not persisted to DB)
- Temporary (cleared after save)
- Frappe-native functionality
pibical/
βββ pibical/
β βββ __init__.py
β βββ hooks.py # Frappe integration hooks
β βββ pibical/
β βββ __init__.py
β βββ custom.py # Main sync logic (800+ lines)
β βββ doctype/ # Custom doctypes (if any)
β βββ fixtures/ # Database customizations
β βββ custom_field.json # Custom fields for User & Event
β βββ client_script.json # Client-side JavaScript
β βββ server_script.json # Server-side scripts
βββ requirements.txt # Python dependencies
βββ setup.py # Package setup
βββ license.txt # MIT License
βββ README.md # This file
βββ CLAUDE.md # AI assistant context
Purpose: Integrates PibiCal with Frappe framework
Key Definitions:
# Document event hooks
doc_events = {
"Event": {
"before_save": "pibical.pibical.custom.sync_caldav_event_by_user",
"on_trash": "pibical.pibical.custom.remove_caldav_event"
}
}
# Scheduled tasks
scheduler_events = {
"cron": {
"*/3 * * * *": [
"pibical.pibical.custom.sync_outside_caldav"
]
}
}
# Fixtures (custom fields)
fixtures = [
{"dt": "Custom Field", "filters": {"module": ["like", "PibiCal"]}},
{"dt": "Client Script", "filters": {"module": ["like", "PibiCal"]}},
{"dt": "Server Script", "filters": {"module": ["like", "PibiCal"]}}
]Purpose: Core synchronization logic
Key Functions (800+ lines total):
-
Utility Functions:
get_user_timezone()- Get timezone from User settingsis_event_modified()- Compare timestamps (Β±1s tolerance)convert_to_utc()- Convert datetime to UTCconvert_from_utc()- Convert UTC to user timezonegenerate_ics_for_event()- Create .ics file for invitations
-
Whitelisted API Methods (callable from client):
@frappe.whitelist() get_calendar(nuser)- Retrieve user's calendars@frappe.whitelist() sync_caldav_event_by_user(doc, method)- Sync event to CalDAV@frappe.whitelist() remove_caldav_event(doc, method)- Delete from CalDAV@frappe.whitelist() send_event_invitations(event_name, recipients)- Email .ics
-
Background Jobs:
sync_outside_caldav()- Main sync from CalDAV to Frappe
-
Helper Functions:
prepare_fp_event(event, cal_event, user_tz)- Convert CalDAV event to Frappe format
Code Flow (sync_caldav_event_by_user):
def sync_caldav_event_by_user(doc, method=None):
# 1. Check loop prevention flag
if doc.flags.get('ignore_caldav_sync'):
return
# 2. Validate sync is enabled
if not doc.sync_with_caldav:
return
# 3. Get user CalDAV credentials
fp_user = frappe.get_doc("User", frappe.session.user)
# 4. Handle calendar changes (delete from old)
if doc.caldav_id_url and calendar_changed:
remove_caldav_event(doc)
# 5. Generate/preserve UID
if not doc.event_uid:
doc.event_uid = generate_uid()
# 6. Build iCalendar object
cal = build_icalendar(doc, user_tz)
# 7. Sync to CalDAV
if is_new_event:
c.save_event(cal.to_ical())
else:
try:
c.save_event(cal.to_ical(), no_create=True) # Smart update
except ConsistencyError:
c.save_event(cal.to_ical()) # Fallback: create
# 8. Show success message
frappe.msgprint(_("Event created/updated on CalDAV server"))Code Flow (sync_outside_caldav):
def sync_outside_caldav():
# 1. Get users with CalDAV credentials
users = frappe.get_list("User", filters=[...])
# 2. Track processed UIDs (prevent duplicates)
sel_uuid = []
# 3. For each user
for user in users:
# 3a. Connect to CalDAV
client = caldav.DAVClient(url, username, password)
calendars = client.principal().calendars()
# 3b. For each calendar
for calendar in calendars:
# 3c. Fetch events (yesterday to +30 days)
events = calendar.date_search(start, end)
# 3d. For each event
for event in events:
# Parse iCalendar data
cal_data = event.data
evento = parse_ical(cal_data)
# Extract UID
uid = evento.decoded('uid')
# Skip if already processed
if uid in sel_uuid:
continue
sel_uuid.append(uid)
# Check if exists in Frappe
fp_event = frappe.get_list("Event", filters=[['event_uid', '=', uid]])
if fp_event:
# Event exists - check if modified
if is_event_modified(fp_event.event_stamp, caldav_stamp):
# Update event
event_doc = frappe.get_doc("Event", fp_event.name)
update_event(event_doc, evento, user_tz)
event_doc.flags.ignore_caldav_sync = True # KEY!
event_doc.save()
else:
# New event - create it
new_event = frappe.new_doc("Event")
prepare_event(new_event, evento, user_tz)
new_event.flags.ignore_caldav_sync = True # KEY!
new_event.save()Purpose: Defines custom fields added to User and Event doctypes
User Fields:
{
"fieldname": "caldav_url",
"fieldtype": "Data",
"label": "CalDAV URL"
},
{
"fieldname": "caldav_username",
"fieldtype": "Data",
"label": "CalDAV Username"
},
{
"fieldname": "caldav_token",
"fieldtype": "Password",
"label": "CalDAV Token"
}Event Fields:
{
"fieldname": "sync_with_caldav",
"fieldtype": "Check",
"label": "Sync with CalDAV"
},
{
"fieldname": "caldav_id_calendar",
"fieldtype": "Data",
"label": "CalDAV ID Calendar"
},
{
"fieldname": "caldav_id_url",
"fieldtype": "Data",
"label": "CalDAV ID URL"
},
{
"fieldname": "event_uid",
"fieldtype": "Data",
"label": "Event UID",
"unique": false
},
{
"fieldname": "event_stamp",
"fieldtype": "Datetime",
"label": "Event Stamp"
}These methods can be called from client-side JavaScript or server-side Python.
Purpose: Retrieve list of CalDAV calendars for a user
Parameters:
nuser(string): User ID
Returns: Array of calendar objects
[
{
"name": "Personal",
"url": "https://cloud.example.com/remote.php/dav/calendars/user/personal/"
},
{
"name": "Work",
"url": "https://cloud.example.com/remote.php/dav/calendars/user/work/"
}
]Usage (JavaScript):
frappe.call({
method: "pibical.pibical.custom.get_calendar",
args: {
nuser: frappe.session.user
},
callback: function(r) {
console.log("Calendars:", r.message);
// Populate dropdown with calendars
}
});Usage (Python):
from pibical.pibical.custom import get_calendar
calendars = get_calendar("john.doe@example.com")
for cal in calendars:
print(f"{cal['name']}: {cal['url']}")Purpose: Sync single event to CalDAV (usually called by hook)
Parameters:
doc: Event document objectmethod: Hook method name (optional)
Returns: None (shows msgprint to user)
Triggers:
- Automatically on
Event.before_savehook - Can be called manually
Behavior:
- Checks
doc.flags.ignore_caldav_sync- skips if True - Creates new event if
doc.event_uidis empty - Updates existing event if
doc.event_uidexists - Falls back to create if update fails (event not found on CalDAV)
Manual Usage (Python):
from pibical.pibical.custom import sync_caldav_event_by_user
event = frappe.get_doc("Event", "EV00001")
sync_caldav_event_by_user(event)Purpose: Delete event from CalDAV server
Parameters:
doc: Event document objectmethod: Hook method name (optional)
Returns: None (shows msgprint to user)
Triggers:
- Automatically on
Event.on_trashhook - Called when "Sync with CalDAV" is disabled
Behavior:
- Tries 3 methods to find and delete event:
- Direct URL lookup
- UID search with date range
- Date scan with UID matching
Messages:
- Success: "Deleted Event in CalDav Calendar [name]"
- Not found: "Event not found in CalDAV calendar"
- Permission error: "Cannot delete due to insufficient permissions"
Purpose: Send .ics calendar invitations via email
Parameters:
event_name(string): Event document name (e.g., "EV00001")recipients(JSON array): List of recipients
[
{
"send_invitation": 1,
"reference_doctype": "Contact",
"reference_docname": "CONT-0001"
}
]Returns:
{
"sent_count": 3,
"total_selected": 3
}Usage (JavaScript):
frappe.call({
method: "pibical.pibical.custom.send_event_invitations",
args: {
event_name: "EV00001",
recipients: [
{
send_invitation: 1,
reference_doctype: "Contact",
reference_docname: "CONT-0001"
}
]
},
callback: function(r) {
frappe.msgprint(`Sent ${r.message.sent_count} invitations`);
}
});Limitations:
- Only works with Contact doctype
- Contact must have email address
- .ics file attached to email
Purpose: Sync events from CalDAV servers to Frappe (background job)
Schedule: Every 3 minutes (configurable in hooks.py)
Parameters: None
Returns: None
Process:
- Gets all enabled users with CalDAV credentials
- For each user, connects to CalDAV server
- Fetches events from yesterday to +30 days
- Compares with Frappe events by UID
- Creates new events or updates modified ones
- Sets
ignore_caldav_syncflag to prevent loops
Manual Trigger:
cd ~/frappe-bench
bench --site your-site-name console
>>> from pibical.pibical.custom import sync_outside_caldav
>>> sync_outside_caldav()
>>> exit()Performance:
- Processes ~5-10 events per second
- Uses event.data (no extra HTTP requests)
- Isolated error handling (one bad event doesn't break sync)
| Metric | Before v2.0 | After v2.0 | Improvement |
|---|---|---|---|
| Sync 10 events | 8.5s | 3.2s | 62% faster |
| Sync 50 events | 45s | 16s | 64% faster |
| Create event | 2.1s | 1.8s | 14% faster |
| Update event | 3.8s | 2.0s | 47% faster |
| HTTP requests/event | 2-4 | 1 | 50-75% reduction |
| DB queries/event | 5-8 | 2-3 | 40-60% reduction |
-
Use
event.dataInstead of HTTP GET:# Before: req = requests.get(event_url) # Extra HTTP request! # After: event_data = url_event.data # Direct access
Impact: 1 fewer HTTP request per event
-
Fetch Only Required Database Fields:
# Before: fields = ['*'] # All fields # After: fields = ['name', 'event_stamp', 'event_uid'] # Only needed
Impact: 40-60% faster DB queries
-
Smart Update Mechanism:
# Before: Delete event + Create new (2 operations) # After: c.save_event(ical, no_create=True) # 1 operation
Impact: 50% fewer CalDAV operations
-
Isolated Error Handling:
# One bad event doesn't stop entire sync for event in events: try: process_event(event) except: continue # Skip this event, process others
Impact: Better reliability, no cascading failures
# Increase sync window
sel_events = c.date_search(
datetime.now().date() - timedelta(days=7),
datetime.now().date() + timedelta(days=60)
)# Reduce sync window
sel_events = c.date_search(
datetime.now().date(), # Today only
datetime.now().date() + timedelta(days=14)
)
# Reduce sync frequency to 5 minutes
scheduler_events = {
"cron": {
"*/5 * * * *": [...]
}
}# Increase sync frequency to 1 minute
scheduler_events = {
"cron": {
"*/1 * * * *": [...]
}
}Warning: More frequent sync = higher server load
# Time a sync operation
bench --site your-site-name console
>>> import time
>>> from pibical.pibical.custom import sync_outside_caldav
>>> start = time.time()
>>> sync_outside_caldav()
>>> duration = time.time() - start
>>> print(f"Sync took {duration:.2f} seconds")Expected: < 1 second per 5 events
Causes:
- Incorrect CalDAV URL
- Wrong username/password
- SSL/TLS issues
- Network connectivity
Solutions:
-
Verify CalDAV URL format:
β https://cloud.example.com/remote.php/dav/principals/ β https://cloud.example.com (missing path) -
Test credentials:
curl -u username:password https://cloud.example.com/remote.php/dav/principals/username/
-
Check SSL certificate:
openssl s_client -connect cloud.example.com:443
-
Ensure no wildcard certificates (not supported by caldav library)
Status: Should not occur in v2.0+
If it does:
1. Disable "Sync with CalDAV" checkbox
2. Save event
3. Re-enable "Sync with CalDAV"
4. Save again
This clears the UID and forces a fresh sync.
Diagnosis:
# 1. Check scheduler status
bench --site your-site-name doctor
# Should show:
# β Scheduler Active: Yes
# 2. Check recent sync jobs
bench --site your-site-name console
>>> import frappe
>>> jobs = frappe.get_all("Scheduled Job Log",
... filters={"scheduled_job_type": "sync_outside_caldav"},
... fields=["creation", "status"],
... order_by="creation desc",
... limit=5)
>>> for j in jobs:
... print(f"{j.creation}: {j.status}")Solutions:
- Enable scheduler:
bench --site your-site-name enable-scheduler - Restart bench:
bench restart - Check Error Log for "PibiCal" entries
Problem: Events showing wrong times
Solution:
-
Set User timezone: User β Settings β Time Zone
-
Verify timezone:
bench --site your-site-name console >>> import frappe >>> user = frappe.get_doc("User", "your.email@example.com") >>> print(f"User timezone: {user.time_zone}")
-
All events stored in UTC on CalDAV, converted to user's local timezone in Frappe
Status: Fixed in v2.0
What it was: Event had UID (marked as "existing") but never actually synced to CalDAV
Solution in v2.0: Automatically detects this and creates the event
Causes:
- UID mismatch between systems
- Race condition during sync
Prevention:
- System checks UID + subject + time before creating
- In-memory tracking prevents duplicates within same sync
Fix duplicates:
bench --site your-site-name console
>>> import frappe
>>> # Find duplicates
>>> events = frappe.get_all("Event",
... fields=["name", "subject", "starts_on"],
... order_by="subject, starts_on")
>>> # Manually delete duplicates in UIEdit sites/your-site-name/site_config.json:
{
"developer_mode": 1,
"logging": 2
}Restart: bench restart
Error Log (Frappe UI):
- Navigate to: Error Log doctype
- Filter:
error LIKE '%PibiCal%' - Sort: Creation desc
Worker Log (Terminal):
tail -f ~/frappe-bench/logs/worker.log | grep -i pibicalWeb Log (Terminal):
tail -f ~/frappe-bench/logs/web.log | grep -i caldav| Category | Severity | Meaning |
|---|---|---|
PibiCal Sync Error |
High | Main sync operation failed |
PibiCal Update Fallback |
Info | Event not found, creating instead (normal) |
PibiCal Sync Parse Error |
Medium | Event data couldn't be parsed |
PibiCal Sync Fetch Error |
Medium | Couldn't fetch event data from CalDAV |
PibiCal Duplicate |
Info | Duplicate detected and skipped (normal) |
CalDAV Connection Error |
High | Server unreachable |
- Check Error Log first for recent errors
- Search GitHub Issues: https://github.com/pibico/pibical/issues
- Create Issue with:
- Frappe version
- PibiCal version/branch
- Error log entries
- Steps to reproduce
- Email Support: pibico.sl@gmail.com
# Clone repository
cd ~/frappe-bench/apps
git clone https://github.com/pibico/pibical.git
cd pibical
# Install in development mode
bench --site your-site-name install-app pibical
# Enable developer mode
# Edit sites/your-site-name/site_config.json:
{
"developer_mode": 1
}
# Restart
bench restart- Python: Follow PEP 8
- Indentation: 2 spaces (Frappe convention)
- Docstrings: Google style
- Comments: Explain "why", not "what"
No automated tests exist - testing is manual through Frappe UI and console.
Test Checklist:
- Create event in Frappe β Check NextCloud
- Update event in Frappe β Check NextCloud
- Create event in NextCloud β Check Frappe
- Update event in NextCloud β Check Frappe
- Delete event in Frappe β Check NextCloud
- All-day events
- Recurring events
- Timezone conversion
- Multiple calendars
Console Debugging:
bench --site your-site-name console
>>> from pibical.pibical.custom import sync_caldav_event_by_user
>>> import frappe
>>> frappe.set_user("Administrator")
>>> event = frappe.get_doc("Event", "EV00001")
>>> sync_caldav_event_by_user(event)Add Debug Prints:
# In custom.py
import sys
print(f"DEBUG: Event UID = {doc.event_uid}", file=sys.stderr)Check stdout/stderr:
tail -f ~/frappe-bench/logs/worker.log-
Add field to Event doctype (via fixture or migration)
-
Update
sync_caldav_event_by_user():# Add to iCalendar event if doc.my_new_field: event.add('x-custom-field', doc.my_new_field)
-
Update
prepare_fp_event():# Parse from CalDAV event if 'x-custom-field' in cal_event: event.my_new_field = str(cal_event.decoded('x-custom-field'))
-
Test both directions
-
Test connection:
import caldav client = caldav.DAVClient(url, username, password) principal = client.principal() calendars = principal.calendars()
-
If compatible, should work out of the box
-
If not, may need server-specific handling
- Fork the repository
- Create feature branch:
git checkout -b feature/amazing-feature - Make changes and test thoroughly
- Commit:
git commit -m 'Add amazing feature' - Push:
git push origin feature/amazing-feature - Create Pull Request on GitHub
PR Requirements:
- Clear description of changes
- Test results (screenshots/logs)
- No breaking changes (or clearly documented)
- Follows code style
Problem: Events synced from CalDAV triggered before_save hook, syncing back to CalDAV (infinite loop)
Solution: Document flags prevent hook during sync
# Set flag when syncing FROM CalDAV
event.flags.ignore_caldav_sync = True
event.save() # Hook checks flag and skipsImpact: 100% of loop incidents eliminated
Problem: Event updates failed with 400 Bad Request
Solution: Use CalDAV's built-in update mechanism
# Smart update
c.save_event(ical_data, no_create=True, no_overwrite=False)Impact: Update operations 47% faster, no more errors
Problem: Events with UID but never synced to CalDAV
Solution: Detect and handle gracefully
except Exception as e:
if "does not exist" in str(e) or "ConsistencyError" in str(e):
c.save_event(ical_data) # Just create itImpact: Seamless handling of edge case
Changes:
- Use
event.datainstead of HTTP GET (-1 request/event) - Fetch only required DB fields (-40-60% query time)
- Isolated error handling (no cascading failures)
Impact:
- 64% faster sync operations
- 50-75% fewer HTTP requests
- 40-60% fewer DB queries
# 1. Backup
bench --site your-site-name backup
# 2. Pull latest code
cd ~/frappe-bench/apps/pibical
git pull origin develop
# 3. Restart
cd ~/frappe-bench
bench restart
bench clear-cache
# 4. Test
# Create/update events in both directionsBreaking Changes: None Data Migration: Not required
MIT License - see license.txt for details
Copyright (c) 2020-2025 PibiCo
- Documentation: This README + CLAUDE.md
- GitHub Issues: https://github.com/pibico/pibical/issues
- GitHub Discussions: https://github.com/pibico/pibical/discussions
- Email: pibico.sl@gmail.com
When reporting issues, include:
-
Environment:
- Frappe version:
bench version - PibiCal branch:
git branch - CalDAV server (NextCloud/ownCloud version)
- Frappe version:
-
Error Details:
- Error Log entries (copy from Error Log doctype)
- Steps to reproduce
- Expected vs actual behavior
-
Logs (if applicable):
tail -100 ~/frappe-bench/logs/worker.log tail -100 ~/frappe-bench/logs/web.log
Q: Can I sync private events? A: No, only "Public" events are synced for privacy/security.
Q: Why 3-minute sync interval?
A: Balance between real-time and server load. Configurable in hooks.py.
Q: Do attachments sync? A: No, file attachments are not synced.
Q: Can I sync multiple calendars? A: Yes, select different calendars for different events.
Q: What happens if CalDAV server is down? A: Errors logged, sync retries on next cycle. Events queued in Frappe.
Q: Can I use with Google Calendar? A: Only if Google Calendar supports CalDAV (limited support). NextCloud/ownCloud recommended.
- Developed by: PibiCo (pibico.sl@gmail.com)
- CalDAV Library: python-caldav
- iCalendar Library: icalendar
- Framework: Frappe
- Lines of Code: ~2,500
- Main Sync Logic: 800+ lines (
custom.py) - Supported CalDAV Servers: NextCloud, ownCloud, and any RFC 4791 compliant
- Active Installations: Multiple production environments
- First Release: 2020
- Latest Version: 2.0 (December 2025)
Made with β€οΈ by PibiCo
For the latest updates, visit: https://github.com/pibico/pibical