From e3984eb5cfa62c816a6353077a3a5ba571b12d09 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 29 Apr 2026 17:49:03 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Datum-Validierung,=20Monatsauswahl,=20C?= =?UTF-8?q?alDAV-Fix,=20wiederkehrende=20Termine=20-=20End-Datum=20passt?= =?UTF-8?q?=20sich=20automatisch=20an=20wenn=20Start=20ge=C3=A4ndert=20wir?= =?UTF-8?q?d=20(Duration=20bleibt=20erhalten)=20-=20Erstellen-Button=20nut?= =?UTF-8?q?zt=20den=20aktuell=20angesehenen=20Tag=20statt=20immer=20heute?= =?UTF-8?q?=20-=20Monatsansicht:=20Einzelklick=20=3D=20Tag=20ausw=C3=A4hle?= =?UTF-8?q?n,=20Doppelklick=20=3D=20Tagesansicht,=20Rechtsklick=20=3D=20Ko?= =?UTF-8?q?ntextmen=C3=BC=20-=20CalDAV=20URL-Matching=20robuster=20(Normal?= =?UTF-8?q?isierung,=20Path-Fallback,=20calendar=5Fid=20Parameter)=20-=20i?= =?UTF-8?q?Cal-Abo-Termine=20sind=20nicht=20mehr=20bearbeitbar=20(Read-Onl?= =?UTF-8?q?y-Schutz)=20-=20Wiederkehrende=20Termine=20mit=20RRULE-Support?= =?UTF-8?q?=20(t=C3=A4glich/w=C3=B6chentlich/monatlich/j=C3=A4hrlich/benut?= =?UTF-8?q?zerdefiniert)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/caldav_client.py | 26 +++- backend/main.py | 6 + backend/models.py | 1 + backend/routers/caldav_router.py | 191 +++++++++++++++++++++--- backend/routers/local_router.py | 6 + frontend/css/app.css | 26 ++++ frontend/index.html | 50 +++++++ frontend/js/calendar.js | 249 +++++++++++++++++++++++++++++-- frontend/js/i18n.js | 18 +++ frontend/js/views/month.js | 36 ++++- requirements.txt | 1 + 11 files changed, 564 insertions(+), 46 deletions(-) diff --git a/backend/caldav_client.py b/backend/caldav_client.py index 49f9ca6..0cacd8b 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 +from icalendar import Calendar, Event, vRecur logger = logging.getLogger(__name__) @@ -105,6 +105,8 @@ def _parse_ics(raw: str, event_url: str) -> List[Dict]: location = str(component.get("LOCATION", "") or "") description = str(component.get("DESCRIPTION", "") or "") color = str(component.get("X-CALENDARR-COLOR", "") or "") + rrule_prop = component.get("RRULE") + rrule_str = rrule_prop.to_ical().decode("utf-8") if rrule_prop else None dtstart_prop = component.get("DTSTART") dtend_prop = component.get("DTEND") @@ -154,6 +156,7 @@ def _parse_ics(raw: str, event_url: str) -> List[Dict]: "location": location, "description": description, "color": color or None, + "rrule": rrule_str, } ) except Exception as exc: @@ -201,6 +204,8 @@ def create_event( event.add("description", data["description"]) if data.get("color"): event.add("x-calendarr-color", data["color"]) + if data.get("rrule"): + event.add("rrule", _parse_rrule_str(data["rrule"])) cal.add_component(event) cal_obj.save_event(cal.to_ical().decode("utf-8")) @@ -247,6 +252,11 @@ def update_event( component["DESCRIPTION"] = data["description"] if "color" in data: component["X-CALENDARR-COLOR"] = data["color"] + if "rrule" in data: + if data["rrule"]: + component["RRULE"] = _parse_rrule_str(data["rrule"]) + elif "RRULE" in component: + del component["RRULE"] new_cal.add_component(component) @@ -260,6 +270,20 @@ def delete_event(url: str, username: str, password: str, event_url: str): resource.delete() +def _parse_rrule_str(rrule_str: str) -> vRecur: + """Parse an RRULE string like 'FREQ=WEEKLY;BYDAY=MO,WE' into a vRecur.""" + params = {} + for part in rrule_str.split(";"): + if "=" not in part: + continue + key, val = part.split("=", 1) + if "," in val: + params[key] = val.split(",") + else: + params[key] = val + return vRecur(params) + + def _parse_dt(s: str) -> datetime: s = s.replace("Z", "+00:00") dt = datetime.fromisoformat(s) diff --git a/backend/main.py b/backend/main.py index bb1327a..c457c27 100644 --- a/backend/main.py +++ b/backend/main.py @@ -83,6 +83,12 @@ def _migrate(): conn.commit() except Exception: pass + try: + conn.execute(text("ALTER TABLE local_events ADD COLUMN rrule TEXT")) + conn.commit() + logging.info("Migration: added rrule to local_events") + except Exception: + pass _migrate() diff --git a/backend/models.py b/backend/models.py index 36fc462..98b65fb 100644 --- a/backend/models.py +++ b/backend/models.py @@ -112,6 +112,7 @@ class LocalEvent(Base): location = Column(String(500), nullable=True) description = Column(Text, nullable=True) color = Column(String(7), nullable=True) + rrule = Column(Text, nullable=True) calendar = relationship("LocalCalendar", back_populates="events") diff --git a/backend/routers/caldav_router.py b/backend/routers/caldav_router.py index c5f3167..6ef8806 100644 --- a/backend/routers/caldav_router.py +++ b/backend/routers/caldav_router.py @@ -1,9 +1,13 @@ import logging +from datetime import datetime as dt_datetime, date as dt_date, timedelta, timezone as dt_timezone from typing import Optional +from urllib.parse import urlparse +from dateutil.rrule import rrulestr from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy.orm import Session +from sqlalchemy import or_ import caldav_client import models @@ -41,6 +45,7 @@ class EventCreate(BaseModel): location: Optional[str] = None description: Optional[str] = None color: Optional[str] = None + rrule: Optional[str] = None class EventUpdate(BaseModel): @@ -51,6 +56,7 @@ class EventUpdate(BaseModel): location: Optional[str] = None description: Optional[str] = None color: Optional[str] = None + rrule: Optional[str] = None def _account_dict(a: models.CalDAVAccount) -> dict: @@ -75,16 +81,124 @@ 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 = [] + try: + ev_start_str = ev.start.replace("Z", "+00:00") + ev_end_str = ev.end.replace("Z", "+00:00") + + if ev.all_day: + ev_start = dt_date.fromisoformat(ev_start_str[:10]) + ev_end = dt_date.fromisoformat(ev_end_str[:10]) + duration = ev_end - ev_start + rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=dt_datetime.combine(ev_start, dt_datetime.min.time())) + r_start = dt_datetime.combine(range_start if isinstance(range_start, dt_date) else range_start.date(), dt_datetime.min.time()) + r_end = dt_datetime.combine(range_end if isinstance(range_end, dt_date) else range_end.date(), dt_datetime.min.time()) + occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True) + for occ in occurrences: + occ_start = occ.date() + occ_end = occ_start + duration + results.append({ + "id": ev.uid, + "url": f"local://{ev.uid}", + "title": ev.title, + "start": occ_start.isoformat(), + "end": occ_end.isoformat(), + "allDay": True, + "location": ev.location or "", + "description": ev.description or "", + "color": ev.color, + "rrule": ev.rrule, + "calendar_id": f"local-{local_cal.id}", + "calendar_name": local_cal.name, + "calendarColor": local_cal.color, + "source": "local", + }) + else: + ev_start = dt_datetime.fromisoformat(ev_start_str) + ev_end = dt_datetime.fromisoformat(ev_end_str) + if ev_start.tzinfo is None: + ev_start = ev_start.replace(tzinfo=dt_timezone.utc) + if ev_end.tzinfo is None: + ev_end = ev_end.replace(tzinfo=dt_timezone.utc) + duration = ev_end - ev_start + rule = rrulestr(f"RRULE:{ev.rrule}", dtstart=ev_start) + r_start = range_start if isinstance(range_start, dt_datetime) else dt_datetime.combine(range_start, dt_datetime.min.time(), tzinfo=dt_timezone.utc) + r_end = range_end if isinstance(range_end, dt_datetime) else dt_datetime.combine(range_end, dt_datetime.min.time(), tzinfo=dt_timezone.utc) + if r_start.tzinfo is None: + r_start = r_start.replace(tzinfo=dt_timezone.utc) + if r_end.tzinfo is None: + 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_end = occ + duration + results.append({ + "id": ev.uid, + "url": f"local://{ev.uid}", + "title": ev.title, + "start": occ.isoformat(), + "end": occ_end.isoformat(), + "allDay": False, + "location": ev.location or "", + "description": ev.description or "", + "color": ev.color, + "rrule": ev.rrule, + "calendar_id": f"local-{local_cal.id}", + "calendar_name": local_cal.name, + "calendarColor": local_cal.color, + "source": "local", + }) + except Exception as exc: + logger.warning("Error expanding recurring event %s: %s", ev.uid, exc) + # Fall back to single event + results.append({ + "id": ev.uid, "url": f"local://{ev.uid}", "title": ev.title, + "start": ev.start, "end": ev.end, "allDay": ev.all_day, + "location": ev.location or "", "description": ev.description or "", + "color": ev.color, "rrule": ev.rrule, + "calendar_id": f"local-{local_cal.id}", "calendar_name": local_cal.name, + "calendarColor": local_cal.color, "source": "local", + }) + return results + + +def _normalize_url(url: str) -> str: + """Normalize URL for comparison: lowercase scheme/host, strip trailing slash.""" + parsed = urlparse(url) + scheme = parsed.scheme.lower() + host = (parsed.hostname or '').lower() + port = parsed.port + if (scheme == 'https' and port == 443) or (scheme == 'http' and port == 80): + port = None + netloc = f"{host}:{port}" if port else host + path = parsed.path.rstrip('/') + return f"{scheme}://{netloc}{path}" + + def _find_account_for_event_url( event_url: str, accounts: list[models.CalDAVAccount] ) -> Optional[models.CalDAVAccount]: + norm_event = _normalize_url(event_url) + # Primary: match against normalized account URL for acc in accounts: - if event_url.startswith(acc.url): + if norm_event.startswith(_normalize_url(acc.url)): return acc - # fallback: check calendar urls + # Fallback: match against normalized calendar URLs for acc in accounts: for cal in acc.calendars: - if event_url.startswith(cal.cal_id): + if norm_event.startswith(_normalize_url(cal.cal_id)): + return acc + # Second fallback: path-only matching + event_path = urlparse(event_url).path.rstrip('/') + for acc in accounts: + acc_path = urlparse(acc.url).path.rstrip('/') + if acc_path and event_path.startswith(acc_path): + return acc + for acc in accounts: + for cal in acc.calendars: + cal_path = urlparse(cal.cal_id).path.rstrip('/') + if cal_path and event_path.startswith(cal_path): return acc return None @@ -302,27 +416,35 @@ def get_events( db.query(models.LocalEvent) .filter( models.LocalEvent.calendar_id == local_cal.id, - models.LocalEvent.start < end, - models.LocalEvent.end > start, + or_( + # Non-recurring events in range + (models.LocalEvent.rrule == None) & (models.LocalEvent.start < end) & (models.LocalEvent.end > start), + # Recurring events: always include so we can expand + models.LocalEvent.rrule != None, + ), ) .all() ) for ev in local_events: - all_events.append({ - "id": ev.uid, - "url": f"local://{ev.uid}", - "title": ev.title, - "start": ev.start, - "end": ev.end, - "allDay": ev.all_day, - "location": ev.location or "", - "description": ev.description or "", - "color": ev.color, - "calendar_id": f"local-{local_cal.id}", - "calendar_name": local_cal.name, - "calendarColor": local_cal.color, - "source": "local", - }) + if ev.rrule: + all_events.extend(_expand_recurring_local(ev, local_cal, start_dt, end_dt)) + else: + all_events.append({ + "id": ev.uid, + "url": f"local://{ev.uid}", + "title": ev.title, + "start": ev.start, + "end": ev.end, + "allDay": ev.all_day, + "location": ev.location or "", + "description": ev.description or "", + "color": ev.color, + "rrule": None, + "calendar_id": f"local-{local_cal.id}", + "calendar_name": local_cal.name, + "calendarColor": local_cal.color, + "source": "local", + }) # ── iCal subscription events ────────────────────────── ical_subs = ( @@ -403,6 +525,7 @@ def create_event( "location": data.location, "description": data.description, "color": data.color, + "rrule": data.rrule, }, ) return {"uid": uid, "calendar_id": data.calendar_id} @@ -414,6 +537,7 @@ def create_event( def update_event( event_id: str, event_url: str = Query(...), + calendar_id: Optional[int] = Query(None), data: EventUpdate = None, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), @@ -423,7 +547,18 @@ def update_event( .filter(models.CalDAVAccount.user_id == current_user.id) .all() ) - account = _find_account_for_event_url(event_url, accounts) + account = None + if calendar_id is not None: + cal = ( + db.query(models.Calendar) + .join(models.CalDAVAccount) + .filter(models.Calendar.id == calendar_id, models.CalDAVAccount.user_id == current_user.id) + .first() + ) + if cal: + account = next((a for a in accounts if a.id == cal.account_id), None) + if not account: + account = _find_account_for_event_url(event_url, accounts) if not account: raise HTTPException(404, "Event not found or not authorized") try: @@ -443,6 +578,7 @@ def update_event( def delete_event( event_id: str, event_url: str = Query(...), + calendar_id: Optional[int] = Query(None), db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): @@ -451,7 +587,18 @@ def delete_event( .filter(models.CalDAVAccount.user_id == current_user.id) .all() ) - account = _find_account_for_event_url(event_url, accounts) + account = None + if calendar_id is not None: + cal = ( + db.query(models.Calendar) + .join(models.CalDAVAccount) + .filter(models.Calendar.id == calendar_id, models.CalDAVAccount.user_id == current_user.id) + .first() + ) + if cal: + account = next((a for a in accounts if a.id == cal.account_id), None) + if not account: + account = _find_account_for_event_url(event_url, accounts) if not account: raise HTTPException(404, "Event not found or not authorized") try: diff --git a/backend/routers/local_router.py b/backend/routers/local_router.py index 405dd52..d0b2900 100644 --- a/backend/routers/local_router.py +++ b/backend/routers/local_router.py @@ -32,6 +32,7 @@ class EventCreate(BaseModel): location: Optional[str] = None description: Optional[str] = None color: Optional[str] = None + rrule: Optional[str] = None class EventUpdate(BaseModel): @@ -42,6 +43,7 @@ class EventUpdate(BaseModel): location: Optional[str] = None description: Optional[str] = None color: Optional[str] = None + rrule: Optional[str] = None def _cal_dict(cal: models.LocalCalendar) -> dict: @@ -64,6 +66,7 @@ def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar) -> dict: "location": ev.location or "", "description": ev.description or "", "color": ev.color, + "rrule": ev.rrule, "calendar_id": f"local-{cal.id}", "calendar_name": cal.name, "calendarColor": cal.color, @@ -180,6 +183,7 @@ def create_event( location=data.location, description=data.description, color=data.color, + rrule=data.rrule, ) db.add(ev) db.commit() @@ -219,6 +223,8 @@ def update_event( ev.description = data.description if data.color is not None: ev.color = data.color + if data.rrule is not None: + ev.rrule = data.rrule if data.rrule else None db.commit() return {"ok": True} diff --git a/frontend/css/app.css b/frontend/css/app.css index ee6ec83..010297b 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -486,6 +486,8 @@ a { color: var(--primary); text-decoration: none; } .month-col:last-child { border-right: 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.other-month .cell-day { color: var(--text-3); } .cell-day { font-size: 12px; font-weight: 500; color: var(--text-2); @@ -785,6 +787,30 @@ a { color: var(--primary); text-decoration: none; } padding: 12px 20px; border-top: 1px solid var(--border); } +/* ── Recurrence UI ─────────────────────────────────────── */ +.rec-weekdays { display: flex; gap: 4px; margin-top: 8px; } +.rec-day-btn { + width: 36px; height: 36px; border-radius: 50%; + border: 1px solid var(--border); background: var(--bg-card); + color: var(--text-2); cursor: pointer; font-size: 12px; + display: flex; align-items: center; justify-content: center; + transition: background var(--transition), color var(--transition); +} +.rec-day-btn:hover { background: var(--bg-hover); } +.rec-day-btn.active { background: var(--primary); color: #fff; border-color: var(--primary); } + +/* ── 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); + min-width: 180px; padding: 4px 0; +} +.ctx-item { + padding: 8px 16px; font-size: 13px; color: var(--text-1); cursor: pointer; +} +.ctx-item:hover { background: var(--bg-hover); } + /* ── Event Popup ────────────────────────────────────────── */ .event-popup { position: fixed; z-index: 600; diff --git a/frontend/index.html b/frontend/index.html index f202162..ae02952 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -238,6 +238,56 @@ +
+ + +
+
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 5faf66a..186c7f9 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -250,7 +250,23 @@ function renderView() { if (state.currentView === 'month') { renderMonth(container, state.currentDate, evs, - date => { state.currentDate = date; state.currentView = 'day'; updateViewButtons(); fetchAndRender(); }, + (date, action, mouseEvent) => { + if (action === 'navigate') { + state.currentDate = date; + state.currentView = 'day'; + updateViewButtons(); + fetchAndRender(); + } else if (action === 'context') { + state.currentDate = date; + showDayContextMenu(date, mouseEvent); + } else { + // 'select' — highlight day without navigating + state.currentDate = date; + renderMiniCal(); + renderView(); + updateTitle(); + } + }, showEventPopup, weekStartDay ); @@ -764,7 +780,7 @@ function bindTopbar() { }); document.getElementById('btn-settings').onclick = openSettingsModal; - document.getElementById('btn-create-event').onclick = () => openNewEventModal(new Date()); + document.getElementById('btn-create-event').onclick = () => openNewEventModal(state.currentDate); // Mouse wheel / trackpad scroll navigation – only for month & quarter let _wheelLast = 0; @@ -837,6 +853,29 @@ function bindSidebar() { }; } +// ── Day Context Menu (month view) ──────────────────────── +function showDayContextMenu(date, mouseEvent) { + document.querySelectorAll('.cal-context-menu').forEach(m => m.remove()); + + const menu = document.createElement('div'); + menu.className = 'cal-context-menu'; + menu.innerHTML = `
${t('ctx_create_event')}
`; + + menu.style.left = mouseEvent.clientX + 'px'; + menu.style.top = mouseEvent.clientY + 'px'; + document.body.appendChild(menu); + + menu.querySelector('[data-action="create"]').onclick = () => { + menu.remove(); + openNewEventModal(date); + }; + + const close = (e) => { + if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', close); } + }; + setTimeout(() => document.addEventListener('click', close), 0); +} + // ── Event Popup ─────────────────────────────────────────── function showEventPopup(ev, anchor) { const popup = document.getElementById('popup-event'); @@ -877,6 +916,11 @@ function showEventPopup(ev, anchor) { popup.style.left = Math.max(8, left) + 'px'; popup.style.top = Math.max(8, top) + 'px'; + // Hide edit/delete for read-only iCal subscription events + const isReadOnly = (ev.source === 'ical'); + document.getElementById('popup-edit').style.display = isReadOnly ? 'none' : ''; + document.getElementById('popup-delete').style.display = isReadOnly ? 'none' : ''; + document.getElementById('popup-edit').onclick = () => { popup.classList.add('hidden'); openEditEventModal(ev); @@ -921,7 +965,7 @@ function showEventPopup(ev, anchor) { const subId = ev.calendar_id.replace('ical-', ''); await api.delete(`/ical/events/${subId}/${encodeURIComponent(ev.id)}`); } else { - await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}`); + await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}&calendar_id=${ev.calendar_id}`); } showToast(t('event_deleted')); fetchAndRender(true); @@ -1005,11 +1049,13 @@ function openNewEventModal(date) { toggleAlldayFields(false); populateCalendarSelect(null); resetColorPicker(''); + resetRecurrenceUI(); document.getElementById('ev-delete').classList.add('hidden'); openModal('modal-event'); } function openEditEventModal(ev) { + if (ev.source === 'ical') { showToast(t('event_readonly'), true); return; } state.editingEvent = ev; state.selectedEventColor = ev.color || ''; @@ -1033,6 +1079,23 @@ function openEditEventModal(ev) { populateCalendarSelect(ev.calendar_id); resetColorPicker(ev.color || ''); + + // Recurrence + const rrule = ev.rrule || ''; + const recSel = document.getElementById('ev-recurrence'); + const customPanel = document.getElementById('ev-recurrence-custom'); + if (!rrule) { + recSel.value = ''; + customPanel.classList.add('hidden'); + } else if (['FREQ=DAILY', 'FREQ=WEEKLY', 'FREQ=MONTHLY', 'FREQ=YEARLY'].includes(rrule)) { + recSel.value = rrule; + customPanel.classList.add('hidden'); + } else { + recSel.value = 'custom'; + customPanel.classList.remove('hidden'); + parseRruleIntoUI(rrule); + } + document.getElementById('ev-delete').classList.remove('hidden'); openModal('modal-event'); } @@ -1050,24 +1113,142 @@ function resetColorPicker(color) { preview.style.background = color || 'var(--primary)'; } +function buildRruleFromUI() { + const sel = document.getElementById('ev-recurrence').value; + if (!sel) return null; + if (sel !== 'custom') return sel; + + const interval = parseInt(document.getElementById('ev-rec-interval').value) || 1; + const freq = document.getElementById('ev-rec-freq').value; + let rule = `FREQ=${freq}`; + if (interval > 1) rule += `;INTERVAL=${interval}`; + + if (freq === 'WEEKLY') { + const days = [...document.querySelectorAll('.rec-day-btn.active')].map(b => b.dataset.day); + if (days.length) rule += `;BYDAY=${days.join(',')}`; + } + + const endType = document.getElementById('ev-rec-end-type').value; + if (endType === 'count') { + rule += `;COUNT=${parseInt(document.getElementById('ev-rec-count').value) || 10}`; + } else if (endType === 'until') { + const until = document.getElementById('ev-rec-until').value; + if (until) rule += `;UNTIL=${until.replace(/-/g, '')}T235959Z`; + } + return rule; +} + +function parseRruleIntoUI(rruleStr) { + const parts = {}; + rruleStr.split(';').forEach(p => { + const [k, v] = p.split('=', 2); + if (k && v) parts[k] = v; + }); + + document.getElementById('ev-rec-interval').value = parts.INTERVAL || '1'; + document.getElementById('ev-rec-freq').value = parts.FREQ || 'DAILY'; + document.getElementById('ev-rec-weekdays').classList.toggle('hidden', parts.FREQ !== 'WEEKLY'); + + // Reset all weekday buttons + document.querySelectorAll('.rec-day-btn').forEach(btn => btn.classList.remove('active')); + if (parts.BYDAY) { + parts.BYDAY.split(',').forEach(day => { + const btn = document.querySelector(`.rec-day-btn[data-day="${day.trim()}"]`); + if (btn) btn.classList.add('active'); + }); + } + + if (parts.COUNT) { + document.getElementById('ev-rec-end-type').value = 'count'; + document.getElementById('ev-rec-count').value = parts.COUNT; + document.getElementById('ev-rec-end-count').classList.remove('hidden'); + document.getElementById('ev-rec-end-until').classList.add('hidden'); + } else if (parts.UNTIL) { + document.getElementById('ev-rec-end-type').value = 'until'; + // Parse UNTIL: 20260501T235959Z → 2026-05-01 + const u = parts.UNTIL.replace('Z', ''); + const formatted = u.length >= 8 ? `${u.slice(0,4)}-${u.slice(4,6)}-${u.slice(6,8)}` : ''; + if (formatted) setDtValue('ev-rec-until', formatted, 'date'); + document.getElementById('ev-rec-end-count').classList.add('hidden'); + document.getElementById('ev-rec-end-until').classList.remove('hidden'); + } else { + document.getElementById('ev-rec-end-type').value = 'never'; + document.getElementById('ev-rec-end-count').classList.add('hidden'); + document.getElementById('ev-rec-end-until').classList.add('hidden'); + } +} + +function resetRecurrenceUI() { + document.getElementById('ev-recurrence').value = ''; + document.getElementById('ev-recurrence-custom').classList.add('hidden'); + document.getElementById('ev-rec-interval').value = '1'; + document.getElementById('ev-rec-freq').value = 'DAILY'; + document.getElementById('ev-rec-weekdays').classList.add('hidden'); + document.querySelectorAll('.rec-day-btn').forEach(btn => btn.classList.remove('active')); + document.getElementById('ev-rec-end-type').value = 'never'; + document.getElementById('ev-rec-end-count').classList.add('hidden'); + document.getElementById('ev-rec-end-until').classList.add('hidden'); +} + function bindEventModal() { document.getElementById('ev-allday').addEventListener('change', e => { toggleAlldayFields(e.target.checked); }); - // Date/time pickers + // Date/time pickers with auto-adjustment logic [ - { displayId: 'ev-start-display', inputId: 'ev-start', mode: 'datetime' }, - { displayId: 'ev-end-display', inputId: 'ev-end', mode: 'datetime' }, - { displayId: 'ev-start-date-display', inputId: 'ev-start-date', mode: 'date' }, - { displayId: 'ev-end-date-display', inputId: 'ev-end-date', mode: 'date' }, - ].forEach(({ displayId, inputId, mode }) => { + { displayId: 'ev-start-display', inputId: 'ev-start', mode: 'datetime', role: 'start' }, + { displayId: 'ev-end-display', inputId: 'ev-end', mode: 'datetime', role: 'end' }, + { displayId: 'ev-start-date-display', inputId: 'ev-start-date', mode: 'date', role: 'start' }, + { displayId: 'ev-end-date-display', inputId: 'ev-end-date', mode: 'date', role: 'end' }, + ].forEach(({ displayId, inputId, mode, role }) => { const disp = document.getElementById(displayId); if (!disp) return; const open = async () => { const current = document.getElementById(inputId)?.value || ''; - const result = await openDatePicker(disp, current, mode); - if (result !== null) setDtValue(inputId, result, mode); + const oldStart = mode === 'datetime' + ? document.getElementById('ev-start').value + : document.getElementById('ev-start-date').value; + const oldEnd = mode === 'datetime' + ? document.getElementById('ev-end').value + : document.getElementById('ev-end-date').value; + + const result = await openDatePicker(disp, current, mode); + if (result === null) return; + setDtValue(inputId, result, mode); + + if (role === 'start') { + // Adjust end to maintain duration + if (mode === 'datetime') { + const os = oldStart ? new Date(oldStart) : null; + const oe = oldEnd ? new Date(oldEnd) : null; + const ns = new Date(result); + const duration = (os && oe && oe > os) ? (oe - os) : 3600000; + const ne = new Date(ns.getTime() + duration); + setDtValue('ev-end', toLocalDatetimeInput(ne), 'datetime'); + } else { + const endVal = document.getElementById('ev-end-date').value; + if (!endVal || endVal < result) { + setDtValue('ev-end-date', result, 'date'); + } + } + } else { + // Validate end is not before start + if (mode === 'datetime') { + const startVal = document.getElementById('ev-start').value; + if (startVal && new Date(result) <= new Date(startVal)) { + const corrected = new Date(new Date(startVal).getTime() + 3600000); + setDtValue('ev-end', toLocalDatetimeInput(corrected), 'datetime'); + showToast(t('error_end_before_start'), true); + } + } else { + const startVal = document.getElementById('ev-start-date').value; + if (startVal && result < startVal) { + setDtValue('ev-end-date', startVal, 'date'); + showToast(t('error_end_before_start'), true); + } + } + } }; disp.addEventListener('click', open); disp.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') open(); }); @@ -1091,6 +1272,41 @@ function bindEventModal() { } }); + // ── Recurrence UI ────────────────────────────────────── + const recSel = document.getElementById('ev-recurrence'); + const customPanel = document.getElementById('ev-recurrence-custom'); + const recFreq = document.getElementById('ev-rec-freq'); + const weekdaysDiv = document.getElementById('ev-rec-weekdays'); + const endTypeSel = document.getElementById('ev-rec-end-type'); + + recSel.addEventListener('change', () => { + customPanel.classList.toggle('hidden', recSel.value !== 'custom'); + }); + + recFreq.addEventListener('change', () => { + weekdaysDiv.classList.toggle('hidden', recFreq.value !== 'WEEKLY'); + }); + + document.querySelectorAll('.rec-day-btn').forEach(btn => { + btn.addEventListener('click', () => btn.classList.toggle('active')); + }); + + endTypeSel.addEventListener('change', () => { + document.getElementById('ev-rec-end-count').classList.toggle('hidden', endTypeSel.value !== 'count'); + document.getElementById('ev-rec-end-until').classList.toggle('hidden', endTypeSel.value !== 'until'); + }); + + const untilDisp = document.getElementById('ev-rec-until-display'); + if (untilDisp) { + const openUntil = async () => { + const current = document.getElementById('ev-rec-until').value || ''; + const result = await openDatePicker(untilDisp, current, 'date'); + if (result !== null) setDtValue('ev-rec-until', result, 'date'); + }; + untilDisp.addEventListener('click', openUntil); + untilDisp.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') openUntil(); }); + } + document.getElementById('ev-save').onclick = async () => { const title = document.getElementById('ev-title').value.trim(); if (!title) { showToast(t('error_enter_title'), true); return; } @@ -1102,6 +1318,7 @@ function bindEventModal() { const loc = document.getElementById('ev-location').value.trim(); const desc = document.getElementById('ev-description').value.trim(); const color = state.selectedEventColor; + const rrule = buildRruleFromUI(); let start, end; if (allDay) { @@ -1127,7 +1344,7 @@ function bindEventModal() { ); } else if (ev.source === 'local') { await api.put(`/local/events/${encodeURIComponent(ev.id)}`, - { title, start, end, allDay, location: loc, description: desc, color: color || null } + { title, start, end, allDay, location: loc, description: desc, color: color || null, rrule: rrule || '' } ); } else if (ev.source === 'ical') { const subId = ev.calendar_id.replace('ical-', ''); @@ -1136,8 +1353,8 @@ function bindEventModal() { ); } else { await api.put( - `/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}`, - { title, start, end, allDay, location: loc, description: desc, color: color || null } + `/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}&calendar_id=${ev.calendar_id}`, + { title, start, end, allDay, location: loc, description: desc, color: color || null, rrule: rrule || '' } ); } showToast(t('event_updated')); @@ -1153,6 +1370,7 @@ function bindEventModal() { await api.post('/local/events', { calendar_id: calId, title, start, end, allDay, location: loc, description: desc, color: color || null, + rrule: rrule || null, }); showToast(t('event_created')); } else { @@ -1160,6 +1378,7 @@ function bindEventModal() { await api.post('/caldav/events', { calendar_id: calId, title, start, end, allDay, location: loc, description: desc, color: color || null, + rrule: rrule || null, }); showToast(t('event_created')); } @@ -1184,7 +1403,7 @@ function bindEventModal() { const subId = ev.calendar_id.replace('ical-', ''); await api.delete(`/ical/events/${subId}/${encodeURIComponent(ev.id)}`); } else { - await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}`); + await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}&calendar_id=${ev.calendar_id}`); } showToast(t('event_deleted')); closeModal('modal-event'); diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index 48de8ae..8400b08 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -142,6 +142,15 @@ const translations = { error_enter_title: 'Bitte Titel eingeben', error_enter_date: 'Bitte Datum eingeben', error_enter_start: 'Bitte Start-Zeit eingeben', + error_end_before_start: 'Ende kann nicht vor dem Start liegen', + ctx_create_event: 'Neuen Termin erstellen', + event_readonly: 'Abonnierte Termine können nicht bearbeitet werden', + rec_label: 'Wiederholung', + rec_none: 'Keine', rec_daily: 'Täglich', rec_weekly: 'Wöchentlich', + rec_monthly: 'Monatlich', rec_yearly: 'Jährlich', rec_custom: 'Benutzerdefiniert…', + rec_every: 'Alle', rec_days: 'Tage', rec_weeks: 'Wochen', rec_months: 'Monate', + rec_ends: 'Endet', rec_never: 'Nie', rec_after_count: 'Nach Anzahl', + rec_on_date: 'Am Datum', rec_occurrences: 'Termine', copy_to_calendar: 'Kopieren nach…', event_copied: 'Termin kopiert', event_updated: 'Termin aktualisiert', event_created: 'Termin erstellt', confirm_delete_event: '"{title}" wirklich löschen?', @@ -337,6 +346,15 @@ const translations = { error_enter_title: 'Please enter a title', error_enter_date: 'Please enter a date', error_enter_start: 'Please enter a start time', + error_end_before_start: 'End cannot be before start', + ctx_create_event: 'Create new event', + event_readonly: 'Subscribed events cannot be edited', + rec_label: 'Recurrence', + rec_none: 'None', rec_daily: 'Daily', rec_weekly: 'Weekly', + rec_monthly: 'Monthly', rec_yearly: 'Yearly', rec_custom: 'Custom…', + rec_every: 'Every', rec_days: 'days', rec_weeks: 'weeks', rec_months: 'months', + rec_ends: 'Ends', rec_never: 'Never', rec_after_count: 'After count', + rec_on_date: 'On date', rec_occurrences: 'occurrences', copy_to_calendar: 'Copy to…', event_copied: 'Event copied', event_updated: 'Event updated', event_created: 'Event created', confirm_delete_event: 'Really delete "{title}"?', diff --git a/frontend/js/views/month.js b/frontend/js/views/month.js index 201636e..72d0e4e 100644 --- a/frontend/js/views/month.js +++ b/frontend/js/views/month.js @@ -1,4 +1,4 @@ -import { isToday, isPast, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js'; +import { isToday, isPast, isSameDay, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js'; import { t } from '../i18n.js'; const LANE_H = 20; // px per lane (event height 18px + 2px gap) @@ -124,10 +124,11 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC rowCells.forEach(cell => { const key = dateKey(cell); const isOther = cell.getMonth() !== primaryMonth; - const todayCls = isToday(cell) ? 'today' : ''; - const otherCls = isOther ? 'other-month' : ''; - const numCls = isToday(cell) ? 'today' : ''; - colsHtml += `
+ const todayCls = isToday(cell) ? 'today' : ''; + const otherCls = isOther ? 'other-month' : ''; + const selectedCls = isSameDay(cell, currentDate) ? 'month-selected' : ''; + const numCls = isToday(cell) ? 'today' : ''; + colsHtml += `
${cell.getDate()}
`; }); @@ -148,6 +149,8 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC // Click handlers via event delegation on the body const body = container.querySelector('.month-body'); + + // Single click: select day (or handle event / more clicks) body.addEventListener('click', e => { // Span event click const spanEl = e.target.closest('.month-span-event'); @@ -161,13 +164,30 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC const moreEl = e.target.closest('.month-more'); if (moreEl) { e.stopPropagation(); - onDayClick(new Date(moreEl.dataset.date + 'T00:00:00')); + onDayClick(new Date(moreEl.dataset.date + 'T00:00:00'), 'navigate'); return; } - // Column click → navigate to day view + // Column click → select day const colEl = e.target.closest('.month-col'); if (colEl) { - onDayClick(new Date(colEl.dataset.date + 'T00:00:00')); + onDayClick(new Date(colEl.dataset.date + 'T00:00:00'), 'select'); + } + }); + + // Double click: navigate to day view + body.addEventListener('dblclick', e => { + const colEl = e.target.closest('.month-col'); + if (colEl && !e.target.closest('.month-span-event')) { + onDayClick(new Date(colEl.dataset.date + 'T00:00:00'), 'navigate'); + } + }); + + // Right click: context menu + body.addEventListener('contextmenu', e => { + const colEl = e.target.closest('.month-col'); + if (colEl) { + e.preventDefault(); + onDayClick(new Date(colEl.dataset.date + 'T00:00:00'), 'context', e); } }); } diff --git a/requirements.txt b/requirements.txt index 22c76ee..d51baa5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,4 @@ requests==2.32.3 pyotp==2.9.0 qrcode[pil]==8.0 Pillow==11.0.0 +python-dateutil==2.9.0