From d4ea097831b990cec43efcf3c103bb5f9d376ecb Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 29 Apr 2026 18:13:12 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Runde-2-Fixes=20=E2=80=93=20Monatsauswah?= =?UTF-8?q?l,=20CalDAV-Update,=20L=C3=B6sch-Dialog,=20EXDATE=20-=20Monatsa?= =?UTF-8?q?nsicht:=20selectedDate=20von=20currentDate=20getrennt,=20Klick?= =?UTF-8?q?=20verschiebt=20View=20nicht=20mehr=20-=20Selected-Day=20Stylin?= =?UTF-8?q?g:=20wei=C3=9Fer=20Text=20auf=20Primary-Hintergrund=20statt=20n?= =?UTF-8?q?ur=20Textfarbe=20-=20Kontextmen=C3=BC:=20--bg-surface=20statt?= =?UTF-8?q?=20fehlendem=20--bg-card=20-=20CalDAV=20Update/Delete:=20parent?= =?UTF-8?q?=20Calendar-Objekt=20=C3=BCbergeben=20(behebt=20NoneType-Fehler?= =?UTF-8?q?)=20-=20HA-Kalender=20im=20Kalender-Selektor=20erg=C3=A4nzt=20-?= =?UTF-8?q?=20Browser-confirm()=20durch=20styled=20Modal-Dialog=20ersetzt?= =?UTF-8?q?=20mit=20Serie/Einzeln-Option=20-=20EXDATE-Support:=20einzelne?= =?UTF-8?q?=20Vorkommen=20wiederkehrender=20Termine=20l=C3=B6schen=20(loka?= =?UTF-8?q?l=20+=20CalDAV)=20-=20Fehlende=20i18n-Keys=20f=C3=BCr=20L=C3=B6?= =?UTF-8?q?sch-Dialog=20erg=C3=A4nzt=20(DE=20+=20EN)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/caldav_client.py | 31 +++++-- backend/main.py | 7 ++ backend/models.py | 1 + backend/routers/caldav_router.py | 33 +++++++- backend/routers/local_router.py | 8 ++ frontend/css/app.css | 6 +- frontend/index.html | 26 ++++++ frontend/js/calendar.js | 138 ++++++++++++++++++++++++------- frontend/js/i18n.js | 8 ++ frontend/js/views/month.js | 5 +- 10 files changed, 220 insertions(+), 43 deletions(-) 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 @@ + + +