diff --git a/backend/caldav_client.py b/backend/caldav_client.py index 0cacd8b..d7a8db2 100644 --- a/backend/caldav_client.py +++ b/backend/caldav_client.py @@ -4,7 +4,7 @@ from datetime import date, datetime, timedelta, timezone from typing import Dict, List, Optional import caldav -from icalendar import Calendar, Event, vRecur +from icalendar import Calendar, Event, vDatetime, vRecur logger = logging.getLogger(__name__) @@ -213,10 +213,15 @@ def create_event( def update_event( - url: str, username: str, password: str, event_url: str, data: Dict + url: str, username: str, password: str, event_url: str, data: Dict, + calendar_url: str = None, ): client = _client(url, username, password) - resource = caldav.Event(client=client, url=event_url) + if calendar_url: + cal_obj = client.calendar(url=calendar_url) + resource = caldav.Event(client=client, url=event_url, parent=cal_obj) + else: + resource = caldav.Event(client=client, url=event_url) resource.load() raw = resource.data @@ -258,15 +263,31 @@ def update_event( elif "RRULE" in component: del component["RRULE"] + if "exdate" in data and data["exdate"]: + # Parse YYYYMMDD string into a proper EXDATE + exdate_str = data["exdate"] + # Determine if event uses dates or datetimes + dtstart_prop = component.get("DTSTART") + if dtstart_prop and isinstance(dtstart_prop.dt, date) and not isinstance(dtstart_prop.dt, datetime): + exdate_val = date(int(exdate_str[:4]), int(exdate_str[4:6]), int(exdate_str[6:8])) + else: + exdate_val = datetime(int(exdate_str[:4]), int(exdate_str[4:6]), int(exdate_str[6:8]), tzinfo=timezone.utc) + component.add("exdate", [exdate_val]) + new_cal.add_component(component) resource.data = new_cal.to_ical().decode("utf-8") resource.save() -def delete_event(url: str, username: str, password: str, event_url: str): +def delete_event(url: str, username: str, password: str, event_url: str, + calendar_url: str = None): client = _client(url, username, password) - resource = caldav.Event(client=client, url=event_url) + if calendar_url: + cal_obj = client.calendar(url=calendar_url) + resource = caldav.Event(client=client, url=event_url, parent=cal_obj) + else: + resource = caldav.Event(client=client, url=event_url) resource.delete() diff --git a/backend/main.py b/backend/main.py index c457c27..46ccbde 100644 --- a/backend/main.py +++ b/backend/main.py @@ -90,6 +90,13 @@ def _migrate(): except Exception: pass + try: + conn.execute(text("ALTER TABLE local_events ADD COLUMN exdate TEXT")) + conn.commit() + logging.info("Migration: added exdate to local_events") + except Exception: + pass + _migrate() app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None) diff --git a/backend/models.py b/backend/models.py index 98b65fb..92a57ec 100644 --- a/backend/models.py +++ b/backend/models.py @@ -113,6 +113,7 @@ class LocalEvent(Base): description = Column(Text, nullable=True) color = Column(String(7), nullable=True) rrule = Column(Text, nullable=True) + exdate = Column(Text, nullable=True) # Comma-separated YYYYMMDD dates to exclude calendar = relationship("LocalCalendar", back_populates="events") diff --git a/backend/routers/caldav_router.py b/backend/routers/caldav_router.py index 6ef8806..447663f 100644 --- a/backend/routers/caldav_router.py +++ b/backend/routers/caldav_router.py @@ -57,6 +57,7 @@ class EventUpdate(BaseModel): description: Optional[str] = None color: Optional[str] = None rrule: Optional[str] = None + exdate: Optional[str] = None def _account_dict(a: models.CalDAVAccount) -> dict: @@ -84,6 +85,13 @@ def _account_dict(a: models.CalDAVAccount) -> dict: def _expand_recurring_local(ev, local_cal, range_start, range_end): """Expand a recurring LocalEvent into individual occurrences within the date range.""" results = [] + # Parse excluded dates + excluded = set() + if ev.exdate: + for d in ev.exdate.split(","): + d = d.strip() + if d: + excluded.add(d) try: ev_start_str = ev.start.replace("Z", "+00:00") ev_end_str = ev.end.replace("Z", "+00:00") @@ -98,6 +106,9 @@ def _expand_recurring_local(ev, local_cal, range_start, range_end): occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True) for occ in occurrences: occ_start = occ.date() + occ_key = occ_start.strftime("%Y%m%d") + if occ_key in excluded: + continue occ_end = occ_start + duration results.append({ "id": ev.uid, @@ -132,6 +143,9 @@ def _expand_recurring_local(ev, local_cal, range_start, range_end): r_end = r_end.replace(tzinfo=dt_timezone.utc) occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True) for occ in occurrences: + occ_key = occ.strftime("%Y%m%d") + if occ_key in excluded: + continue occ_end = occ + duration results.append({ "id": ev.uid, @@ -548,6 +562,7 @@ def update_event( .all() ) account = None + cal_url = None if calendar_id is not None: cal = ( db.query(models.Calendar) @@ -557,8 +572,15 @@ def update_event( ) if cal: account = next((a for a in accounts if a.id == cal.account_id), None) + cal_url = cal.cal_id if not account: account = _find_account_for_event_url(event_url, accounts) + # Try to find the calendar URL for the account + if account and not cal_url: + for c in account.calendars: + if event_url.startswith(c.cal_id) or event_url.startswith(_normalize_url(c.cal_id)): + cal_url = c.cal_id + break if not account: raise HTTPException(404, "Event not found or not authorized") try: @@ -568,6 +590,7 @@ def update_event( account.password, event_url, data.model_dump(exclude_none=True) if data else {}, + calendar_url=cal_url, ) return {"ok": True} except Exception as exc: @@ -588,6 +611,7 @@ def delete_event( .all() ) account = None + cal_url = None if calendar_id is not None: cal = ( db.query(models.Calendar) @@ -597,13 +621,20 @@ def delete_event( ) if cal: account = next((a for a in accounts if a.id == cal.account_id), None) + cal_url = cal.cal_id if not account: account = _find_account_for_event_url(event_url, accounts) + if account and not cal_url: + for c in account.calendars: + if event_url.startswith(c.cal_id) or event_url.startswith(_normalize_url(c.cal_id)): + cal_url = c.cal_id + break if not account: raise HTTPException(404, "Event not found or not authorized") try: caldav_client.delete_event( - account.url, account.username, account.password, event_url + account.url, account.username, account.password, event_url, + calendar_url=cal_url, ) return {"ok": True} except Exception as exc: diff --git a/backend/routers/local_router.py b/backend/routers/local_router.py index d0b2900..e7d6b97 100644 --- a/backend/routers/local_router.py +++ b/backend/routers/local_router.py @@ -44,6 +44,7 @@ class EventUpdate(BaseModel): description: Optional[str] = None color: Optional[str] = None rrule: Optional[str] = None + exdate: Optional[str] = None def _cal_dict(cal: models.LocalCalendar) -> dict: @@ -67,6 +68,7 @@ def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar) -> dict: "description": ev.description or "", "color": ev.color, "rrule": ev.rrule, + "exdate": ev.exdate, "calendar_id": f"local-{cal.id}", "calendar_name": cal.name, "calendarColor": cal.color, @@ -225,6 +227,12 @@ def update_event( ev.color = data.color if data.rrule is not None: ev.rrule = data.rrule if data.rrule else None + if data.exdate is not None: + existing = ev.exdate or "" + dates = [d for d in existing.split(",") if d] + if data.exdate not in dates: + dates.append(data.exdate) + ev.exdate = ",".join(dates) db.commit() return {"ok": True} diff --git a/frontend/css/app.css b/frontend/css/app.css index 010297b..0bd114d 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -487,7 +487,7 @@ a { color: var(--primary); text-decoration: none; } .month-col:hover { background: var(--bg-hover); } .month-col.today { background: rgba(66,133,244,.08); } .month-col.month-selected { background: var(--primary-dim); } -.month-col.month-selected .cell-day { color: var(--primary); font-weight: 600; } +.month-col.month-selected .cell-day { background: var(--primary); color: #fff; font-weight: 700; } .month-col.other-month .cell-day { color: var(--text-3); } .cell-day { font-size: 12px; font-weight: 500; color: var(--text-2); @@ -802,8 +802,8 @@ a { color: var(--primary); text-decoration: none; } /* ── Day Context Menu ──────────────────────────────────── */ .cal-context-menu { position: fixed; z-index: 1000; - background: var(--bg-card); border: 1px solid var(--border); - border-radius: var(--radius-sm); box-shadow: 0 4px 16px rgba(0,0,0,.3); + background: var(--bg-surface); border: 1px solid var(--border); + border-radius: var(--radius-sm); box-shadow: 0 4px 16px rgba(0,0,0,.5); min-width: 180px; padding: 4px 0; } .ctx-item { diff --git a/frontend/index.html b/frontend/index.html index ae02952..40795b9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -317,6 +317,32 @@ + + +