Skip to content

Keep 29 February birthdays in every year (on top of #2) - #3

Open
HubEight wants to merge 16 commits into
anatosun:mainfrom
HubEight:fix/leap-day-birthday
Open

HubEight wants to merge 16 commits into
anatosun:mainfrom
HubEight:fix/leap-day-birthday

Conversation

@HubEight

Copy link
Copy Markdown

This is based on #2 (which is based on #1). Only the last commit is new here; the other commits come from #2 and will drop out of the diff once it is merged.

Birthdays on 29 February were broken in two ways:

  • In a common year birthday.replace(year=year) raises "day is out of range for month", so the event is neither created nor updated. With the default run (current and next year) that means no event at all in three out of four years.
  • An event created in a leap year uses plain FREQ=YEARLY. RFC 5545 skips invalid dates, so it only shows up every four years (2028, 2032, 2036).

Such birthdays now fall on 28 February in common years and on 29 February in leap years, using RRULE:FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=-1. The UID keeps month/day from the contact (...0229), so the orphan cleanup from #2 still matches the event. UIDs of all other birthdays are unchanged.

I checked it against iCloud: the rule is accepted, the time-range lookup finds the event on 2027-02-28 and 2028-02-29 (not on 2028-02-28), and a second run creates no duplicate. bdaysync/test_sync.py has three new tests for this (cd bdaysync && python -m unittest test_sync).

🤖 Generated with Claude Code

4350pChris and others added 16 commits August 12, 2026 04:12
PR #1 discovers addressbooks via DAV/CardDAV namespaces, then only
keeps members whose getcontenttype contains "vcard". iCloud omits that
property and names contacts *.vcf, so listing returned zero URLs.

Treat a vcard MIME type or a .vcf href as a contact, and skip collection
hrefs that end with a slash.
Build on push to main and fix/carddav-icloud-vcf, push to
ghcr.io/hubeight/bdaysync. Do not tag latest.
The previous commit replaced the repo workflow that was already
running on this branch via workflow_dispatch.
Trivy failed because it used HubEight (must be lowercase) and
fix/carddav-icloud-vcf (slash is invalid in a Docker tag). Use the
same sanitized tag docker/metadata-action publishes.
iCloud lookups occasionally fail with ENETUNREACH (IPv6 without a
route). Retry the GET up to three times with a short backoff so one
blip does not drop a contact for the whole run.
After a complete CardDAV fetch, remove calendar events whose UID
matches birthday-{slug}-{YYYYMMDD} if that slug is not in the current
set of contacts that still have a birthday. Skip delete when any vCard
GET failed or the calendar listing is empty.
The Trivy image-ref fix is for this fork's GHCR job (HubEight vs
hubeight, slashes in branch tags). It does not belong in a PR to
anatosun/bdaysync.
fetch_complete compared fetched vCards with listed ones, so a failed
addressbook listing (HTTP error, exception, broken XML) was never
counted and still yielded complete=True. Unparseable birthdays were
counted as fetched, and an empty contact list with a complete fetch
deleted every birthday event.

- Clear fetch_complete on any listing, download or parse failure;
  _parse_vcard now raises on unreadable data and returns None only
  when there is no BDAY. Listing PROPFIND gets a timeout.
- Restore main_sync returning False when no contacts were found, so
  orphan delete never runs with an empty set.
- Match orphans on name slug plus month/day, so a changed birthday
  replaces the old event instead of leaving a duplicate.
- Share the UID slug between event creation, lookup and orphan delete.
- The retry docstring blamed IPv6: urllib3 tries every resolved
  address and IPv4 sorts first, so ENETUNREACH is only the last error.

Add test_sync.py covering each case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Orphan delete removes calendar data, so it is opt-in. Wired like the
other BIRTHDAY_* settings: config, .env.template, docker-compose and
README, and logged with the event configuration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
birthday.replace(year=year) raised for 29 February in common years, so
the event was never created or updated there. Created in a leap year,
plain FREQ=YEARLY only recurs every four years (RFC 5545 skips invalid
dates).

Such birthdays now fall on 28 February in common years with
FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=-1. The UID keeps month/day from the
contact, so orphan delete still matches it. Normal UIDs are unchanged.

Checked against iCloud: the rule is accepted, the time-range lookup
finds the event on 2027-02-28 and 2028-02-29, and a second run creates
no duplicate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants