feat: Datum-Validierung, Monatsauswahl, CalDAV-Fix, wiederkehrende Termine

- End-Datum passt sich automatisch an wenn Start geändert wird (Duration bleibt erhalten)
- Erstellen-Button nutzt den aktuell angesehenen Tag statt immer heute
- Monatsansicht: Einzelklick = Tag auswählen, Doppelklick = Tagesansicht, Rechtsklick = Kontextmenü
- CalDAV URL-Matching robuster (Normalisierung, Path-Fallback, calendar_id Parameter)
- iCal-Abo-Termine sind nicht mehr bearbeitbar (Read-Only-Schutz)
- Wiederkehrende Termine mit RRULE-Support (täglich/wöchentlich/monatlich/jährlich/benutzerdefiniert)
This commit is contained in:
Guido Schmit
2026-04-29 17:49:03 +02:00
parent 9a59911156
commit 013fb3dbc2
11 changed files with 564 additions and 46 deletions

View File

@@ -4,7 +4,7 @@ from datetime import date, datetime, timedelta, timezone
from typing import Dict, List, Optional from typing import Dict, List, Optional
import caldav import caldav
from icalendar import Calendar, Event from icalendar import Calendar, Event, vRecur
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -105,6 +105,8 @@ def _parse_ics(raw: str, event_url: str) -> List[Dict]:
location = str(component.get("LOCATION", "") or "") location = str(component.get("LOCATION", "") or "")
description = str(component.get("DESCRIPTION", "") or "") description = str(component.get("DESCRIPTION", "") or "")
color = str(component.get("X-CALENDARR-COLOR", "") 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") dtstart_prop = component.get("DTSTART")
dtend_prop = component.get("DTEND") dtend_prop = component.get("DTEND")
@@ -154,6 +156,7 @@ def _parse_ics(raw: str, event_url: str) -> List[Dict]:
"location": location, "location": location,
"description": description, "description": description,
"color": color or None, "color": color or None,
"rrule": rrule_str,
} }
) )
except Exception as exc: except Exception as exc:
@@ -201,6 +204,8 @@ def create_event(
event.add("description", data["description"]) event.add("description", data["description"])
if data.get("color"): if data.get("color"):
event.add("x-calendarr-color", data["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.add_component(event)
cal_obj.save_event(cal.to_ical().decode("utf-8")) cal_obj.save_event(cal.to_ical().decode("utf-8"))
@@ -247,6 +252,11 @@ def update_event(
component["DESCRIPTION"] = data["description"] component["DESCRIPTION"] = data["description"]
if "color" in data: if "color" in data:
component["X-CALENDARR-COLOR"] = data["color"] 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) new_cal.add_component(component)
@@ -260,6 +270,20 @@ def delete_event(url: str, username: str, password: str, event_url: str):
resource.delete() 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: def _parse_dt(s: str) -> datetime:
s = s.replace("Z", "+00:00") s = s.replace("Z", "+00:00")
dt = datetime.fromisoformat(s) dt = datetime.fromisoformat(s)

View File

@@ -83,6 +83,12 @@ def _migrate():
conn.commit() conn.commit()
except Exception: except Exception:
pass 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() _migrate()

View File

@@ -112,6 +112,7 @@ class LocalEvent(Base):
location = Column(String(500), nullable=True) location = Column(String(500), nullable=True)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
color = Column(String(7), nullable=True) color = Column(String(7), nullable=True)
rrule = Column(Text, nullable=True)
calendar = relationship("LocalCalendar", back_populates="events") calendar = relationship("LocalCalendar", back_populates="events")

View File

@@ -1,9 +1,13 @@
import logging import logging
from datetime import datetime as dt_datetime, date as dt_date, timedelta, timezone as dt_timezone
from typing import Optional from typing import Optional
from urllib.parse import urlparse
from dateutil.rrule import rrulestr
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import or_
import caldav_client import caldav_client
import models import models
@@ -41,6 +45,7 @@ class EventCreate(BaseModel):
location: Optional[str] = None location: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
color: Optional[str] = None color: Optional[str] = None
rrule: Optional[str] = None
class EventUpdate(BaseModel): class EventUpdate(BaseModel):
@@ -51,6 +56,7 @@ class EventUpdate(BaseModel):
location: Optional[str] = None location: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
color: Optional[str] = None color: Optional[str] = None
rrule: Optional[str] = None
def _account_dict(a: models.CalDAVAccount) -> dict: 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( def _find_account_for_event_url(
event_url: str, accounts: list[models.CalDAVAccount] event_url: str, accounts: list[models.CalDAVAccount]
) -> Optional[models.CalDAVAccount]: ) -> Optional[models.CalDAVAccount]:
norm_event = _normalize_url(event_url)
# Primary: match against normalized account URL
for acc in accounts: for acc in accounts:
if event_url.startswith(acc.url): if norm_event.startswith(_normalize_url(acc.url)):
return acc return acc
# fallback: check calendar urls # Fallback: match against normalized calendar URLs
for acc in accounts: for acc in accounts:
for cal in acc.calendars: 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 acc
return None return None
@@ -302,27 +416,35 @@ def get_events(
db.query(models.LocalEvent) db.query(models.LocalEvent)
.filter( .filter(
models.LocalEvent.calendar_id == local_cal.id, models.LocalEvent.calendar_id == local_cal.id,
models.LocalEvent.start < end, or_(
models.LocalEvent.end > start, # 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() .all()
) )
for ev in local_events: for ev in local_events:
all_events.append({ if ev.rrule:
"id": ev.uid, all_events.extend(_expand_recurring_local(ev, local_cal, start_dt, end_dt))
"url": f"local://{ev.uid}", else:
"title": ev.title, all_events.append({
"start": ev.start, "id": ev.uid,
"end": ev.end, "url": f"local://{ev.uid}",
"allDay": ev.all_day, "title": ev.title,
"location": ev.location or "", "start": ev.start,
"description": ev.description or "", "end": ev.end,
"color": ev.color, "allDay": ev.all_day,
"calendar_id": f"local-{local_cal.id}", "location": ev.location or "",
"calendar_name": local_cal.name, "description": ev.description or "",
"calendarColor": local_cal.color, "color": ev.color,
"source": "local", "rrule": None,
}) "calendar_id": f"local-{local_cal.id}",
"calendar_name": local_cal.name,
"calendarColor": local_cal.color,
"source": "local",
})
# ── iCal subscription events ────────────────────────── # ── iCal subscription events ──────────────────────────
ical_subs = ( ical_subs = (
@@ -403,6 +525,7 @@ def create_event(
"location": data.location, "location": data.location,
"description": data.description, "description": data.description,
"color": data.color, "color": data.color,
"rrule": data.rrule,
}, },
) )
return {"uid": uid, "calendar_id": data.calendar_id} return {"uid": uid, "calendar_id": data.calendar_id}
@@ -414,6 +537,7 @@ def create_event(
def update_event( def update_event(
event_id: str, event_id: str,
event_url: str = Query(...), event_url: str = Query(...),
calendar_id: Optional[int] = Query(None),
data: EventUpdate = None, data: EventUpdate = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user), current_user: models.User = Depends(get_current_user),
@@ -423,7 +547,18 @@ def update_event(
.filter(models.CalDAVAccount.user_id == current_user.id) .filter(models.CalDAVAccount.user_id == current_user.id)
.all() .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: if not account:
raise HTTPException(404, "Event not found or not authorized") raise HTTPException(404, "Event not found or not authorized")
try: try:
@@ -443,6 +578,7 @@ def update_event(
def delete_event( def delete_event(
event_id: str, event_id: str,
event_url: str = Query(...), event_url: str = Query(...),
calendar_id: Optional[int] = Query(None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user), current_user: models.User = Depends(get_current_user),
): ):
@@ -451,7 +587,18 @@ def delete_event(
.filter(models.CalDAVAccount.user_id == current_user.id) .filter(models.CalDAVAccount.user_id == current_user.id)
.all() .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: if not account:
raise HTTPException(404, "Event not found or not authorized") raise HTTPException(404, "Event not found or not authorized")
try: try:

View File

@@ -32,6 +32,7 @@ class EventCreate(BaseModel):
location: Optional[str] = None location: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
color: Optional[str] = None color: Optional[str] = None
rrule: Optional[str] = None
class EventUpdate(BaseModel): class EventUpdate(BaseModel):
@@ -42,6 +43,7 @@ class EventUpdate(BaseModel):
location: Optional[str] = None location: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
color: Optional[str] = None color: Optional[str] = None
rrule: Optional[str] = None
def _cal_dict(cal: models.LocalCalendar) -> dict: 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 "", "location": ev.location or "",
"description": ev.description or "", "description": ev.description or "",
"color": ev.color, "color": ev.color,
"rrule": ev.rrule,
"calendar_id": f"local-{cal.id}", "calendar_id": f"local-{cal.id}",
"calendar_name": cal.name, "calendar_name": cal.name,
"calendarColor": cal.color, "calendarColor": cal.color,
@@ -180,6 +183,7 @@ def create_event(
location=data.location, location=data.location,
description=data.description, description=data.description,
color=data.color, color=data.color,
rrule=data.rrule,
) )
db.add(ev) db.add(ev)
db.commit() db.commit()
@@ -219,6 +223,8 @@ def update_event(
ev.description = data.description ev.description = data.description
if data.color is not None: if data.color is not None:
ev.color = data.color ev.color = data.color
if data.rrule is not None:
ev.rrule = data.rrule if data.rrule else None
db.commit() db.commit()
return {"ok": True} return {"ok": True}

View File

@@ -486,6 +486,8 @@ a { color: var(--primary); text-decoration: none; }
.month-col:last-child { border-right: none; } .month-col:last-child { border-right: none; }
.month-col:hover { background: var(--bg-hover); } .month-col:hover { background: var(--bg-hover); }
.month-col.today { background: rgba(66,133,244,.08); } .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); } .month-col.other-month .cell-day { color: var(--text-3); }
.cell-day { .cell-day {
font-size: 12px; font-weight: 500; color: var(--text-2); 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); 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 ────────────────────────────────────────── */
.event-popup { .event-popup {
position: fixed; z-index: 600; position: fixed; z-index: 600;

View File

@@ -238,6 +238,56 @@
</div> </div>
</div> </div>
</div> </div>
<div class="form-group">
<label id="ev-rec-label">Wiederholung</label>
<select id="ev-recurrence">
<option value="">Keine</option>
<option value="FREQ=DAILY">Täglich</option>
<option value="FREQ=WEEKLY">Wöchentlich</option>
<option value="FREQ=MONTHLY">Monatlich</option>
<option value="FREQ=YEARLY">Jährlich</option>
<option value="custom">Benutzerdefiniert…</option>
</select>
</div>
<div id="ev-recurrence-custom" class="form-group hidden">
<div class="form-row" style="gap:8px;align-items:center">
<label style="white-space:nowrap" id="ev-rec-every-label">Alle</label>
<input type="number" id="ev-rec-interval" value="1" min="1" max="99" style="width:60px" />
<select id="ev-rec-freq">
<option value="DAILY">Tage</option>
<option value="WEEKLY">Wochen</option>
<option value="MONTHLY">Monate</option>
</select>
</div>
<div id="ev-rec-weekdays" class="rec-weekdays hidden">
<button type="button" class="rec-day-btn" data-day="MO">Mo</button>
<button type="button" class="rec-day-btn" data-day="TU">Di</button>
<button type="button" class="rec-day-btn" data-day="WE">Mi</button>
<button type="button" class="rec-day-btn" data-day="TH">Do</button>
<button type="button" class="rec-day-btn" data-day="FR">Fr</button>
<button type="button" class="rec-day-btn" data-day="SA">Sa</button>
<button type="button" class="rec-day-btn" data-day="SU">So</button>
</div>
<div class="form-row" style="gap:8px;align-items:center;margin-top:8px">
<label id="ev-rec-ends-label">Endet</label>
<select id="ev-rec-end-type">
<option value="never">Nie</option>
<option value="count">Nach Anzahl</option>
<option value="until">Am Datum</option>
</select>
</div>
<div id="ev-rec-end-count" class="hidden" style="margin-top:4px">
<input type="number" id="ev-rec-count" value="10" min="1" max="999" style="width:80px" />
<span id="ev-rec-occ-label"> Termine</span>
</div>
<div id="ev-rec-end-until" class="hidden" style="margin-top:4px">
<input type="hidden" id="ev-rec-until" />
<div class="dt-display" id="ev-rec-until-display" tabindex="0" role="button">
<span class="dt-display-text"></span>
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v5H7z"/></svg>
</div>
</div>
</div>
<div class="form-group"> <div class="form-group">
<label>Kalender</label> <label>Kalender</label>
<select id="ev-calendar"></select> <select id="ev-calendar"></select>

View File

@@ -250,7 +250,23 @@ function renderView() {
if (state.currentView === 'month') { if (state.currentView === 'month') {
renderMonth(container, state.currentDate, evs, 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, showEventPopup,
weekStartDay weekStartDay
); );
@@ -764,7 +780,7 @@ function bindTopbar() {
}); });
document.getElementById('btn-settings').onclick = openSettingsModal; 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 // Mouse wheel / trackpad scroll navigation only for month & quarter
let _wheelLast = 0; 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 = `<div class="ctx-item" data-action="create">${t('ctx_create_event')}</div>`;
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 ─────────────────────────────────────────── // ── Event Popup ───────────────────────────────────────────
function showEventPopup(ev, anchor) { function showEventPopup(ev, anchor) {
const popup = document.getElementById('popup-event'); const popup = document.getElementById('popup-event');
@@ -877,6 +916,11 @@ function showEventPopup(ev, anchor) {
popup.style.left = Math.max(8, left) + 'px'; popup.style.left = Math.max(8, left) + 'px';
popup.style.top = Math.max(8, top) + '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 = () => { document.getElementById('popup-edit').onclick = () => {
popup.classList.add('hidden'); popup.classList.add('hidden');
openEditEventModal(ev); openEditEventModal(ev);
@@ -921,7 +965,7 @@ function showEventPopup(ev, anchor) {
const subId = ev.calendar_id.replace('ical-', ''); const subId = ev.calendar_id.replace('ical-', '');
await api.delete(`/ical/events/${subId}/${encodeURIComponent(ev.id)}`); await api.delete(`/ical/events/${subId}/${encodeURIComponent(ev.id)}`);
} else { } 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')); showToast(t('event_deleted'));
fetchAndRender(true); fetchAndRender(true);
@@ -1005,11 +1049,13 @@ function openNewEventModal(date) {
toggleAlldayFields(false); toggleAlldayFields(false);
populateCalendarSelect(null); populateCalendarSelect(null);
resetColorPicker(''); resetColorPicker('');
resetRecurrenceUI();
document.getElementById('ev-delete').classList.add('hidden'); document.getElementById('ev-delete').classList.add('hidden');
openModal('modal-event'); openModal('modal-event');
} }
function openEditEventModal(ev) { function openEditEventModal(ev) {
if (ev.source === 'ical') { showToast(t('event_readonly'), true); return; }
state.editingEvent = ev; state.editingEvent = ev;
state.selectedEventColor = ev.color || ''; state.selectedEventColor = ev.color || '';
@@ -1033,6 +1079,23 @@ function openEditEventModal(ev) {
populateCalendarSelect(ev.calendar_id); populateCalendarSelect(ev.calendar_id);
resetColorPicker(ev.color || ''); 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'); document.getElementById('ev-delete').classList.remove('hidden');
openModal('modal-event'); openModal('modal-event');
} }
@@ -1050,24 +1113,142 @@ function resetColorPicker(color) {
preview.style.background = color || 'var(--primary)'; 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() { function bindEventModal() {
document.getElementById('ev-allday').addEventListener('change', e => { document.getElementById('ev-allday').addEventListener('change', e => {
toggleAlldayFields(e.target.checked); 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-start-display', inputId: 'ev-start', mode: 'datetime', role: 'start' },
{ displayId: 'ev-end-display', inputId: 'ev-end', mode: 'datetime' }, { displayId: 'ev-end-display', inputId: 'ev-end', mode: 'datetime', role: 'end' },
{ displayId: 'ev-start-date-display', inputId: 'ev-start-date', mode: 'date' }, { displayId: 'ev-start-date-display', inputId: 'ev-start-date', mode: 'date', role: 'start' },
{ displayId: 'ev-end-date-display', inputId: 'ev-end-date', mode: 'date' }, { displayId: 'ev-end-date-display', inputId: 'ev-end-date', mode: 'date', role: 'end' },
].forEach(({ displayId, inputId, mode }) => { ].forEach(({ displayId, inputId, mode, role }) => {
const disp = document.getElementById(displayId); const disp = document.getElementById(displayId);
if (!disp) return; if (!disp) return;
const open = async () => { const open = async () => {
const current = document.getElementById(inputId)?.value || ''; const current = document.getElementById(inputId)?.value || '';
const result = await openDatePicker(disp, current, mode); const oldStart = mode === 'datetime'
if (result !== null) setDtValue(inputId, result, mode); ? 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('click', open);
disp.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') 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 () => { document.getElementById('ev-save').onclick = async () => {
const title = document.getElementById('ev-title').value.trim(); const title = document.getElementById('ev-title').value.trim();
if (!title) { showToast(t('error_enter_title'), true); return; } if (!title) { showToast(t('error_enter_title'), true); return; }
@@ -1102,6 +1318,7 @@ function bindEventModal() {
const loc = document.getElementById('ev-location').value.trim(); const loc = document.getElementById('ev-location').value.trim();
const desc = document.getElementById('ev-description').value.trim(); const desc = document.getElementById('ev-description').value.trim();
const color = state.selectedEventColor; const color = state.selectedEventColor;
const rrule = buildRruleFromUI();
let start, end; let start, end;
if (allDay) { if (allDay) {
@@ -1127,7 +1344,7 @@ function bindEventModal() {
); );
} else if (ev.source === 'local') { } else if (ev.source === 'local') {
await api.put(`/local/events/${encodeURIComponent(ev.id)}`, 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') { } else if (ev.source === 'ical') {
const subId = ev.calendar_id.replace('ical-', ''); const subId = ev.calendar_id.replace('ical-', '');
@@ -1136,8 +1353,8 @@ function bindEventModal() {
); );
} else { } else {
await api.put( await api.put(
`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}`, `/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 } { title, start, end, allDay, location: loc, description: desc, color: color || null, rrule: rrule || '' }
); );
} }
showToast(t('event_updated')); showToast(t('event_updated'));
@@ -1153,6 +1370,7 @@ function bindEventModal() {
await api.post('/local/events', { await api.post('/local/events', {
calendar_id: calId, title, start, end, allDay, calendar_id: calId, title, start, end, allDay,
location: loc, description: desc, color: color || null, location: loc, description: desc, color: color || null,
rrule: rrule || null,
}); });
showToast(t('event_created')); showToast(t('event_created'));
} else { } else {
@@ -1160,6 +1378,7 @@ function bindEventModal() {
await api.post('/caldav/events', { await api.post('/caldav/events', {
calendar_id: calId, title, start, end, allDay, calendar_id: calId, title, start, end, allDay,
location: loc, description: desc, color: color || null, location: loc, description: desc, color: color || null,
rrule: rrule || null,
}); });
showToast(t('event_created')); showToast(t('event_created'));
} }
@@ -1184,7 +1403,7 @@ function bindEventModal() {
const subId = ev.calendar_id.replace('ical-', ''); const subId = ev.calendar_id.replace('ical-', '');
await api.delete(`/ical/events/${subId}/${encodeURIComponent(ev.id)}`); await api.delete(`/ical/events/${subId}/${encodeURIComponent(ev.id)}`);
} else { } 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')); showToast(t('event_deleted'));
closeModal('modal-event'); closeModal('modal-event');

View File

@@ -142,6 +142,15 @@ const translations = {
error_enter_title: 'Bitte Titel eingeben', error_enter_title: 'Bitte Titel eingeben',
error_enter_date: 'Bitte Datum eingeben', error_enter_date: 'Bitte Datum eingeben',
error_enter_start: 'Bitte Start-Zeit 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', copy_to_calendar: 'Kopieren nach…', event_copied: 'Termin kopiert',
event_updated: 'Termin aktualisiert', event_created: 'Termin erstellt', event_updated: 'Termin aktualisiert', event_created: 'Termin erstellt',
confirm_delete_event: '"{title}" wirklich löschen?', confirm_delete_event: '"{title}" wirklich löschen?',
@@ -337,6 +346,15 @@ const translations = {
error_enter_title: 'Please enter a title', error_enter_title: 'Please enter a title',
error_enter_date: 'Please enter a date', error_enter_date: 'Please enter a date',
error_enter_start: 'Please enter a start time', 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', copy_to_calendar: 'Copy to…', event_copied: 'Event copied',
event_updated: 'Event updated', event_created: 'Event created', event_updated: 'Event updated', event_created: 'Event created',
confirm_delete_event: 'Really delete "{title}"?', confirm_delete_event: 'Really delete "{title}"?',

View File

@@ -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'; import { t } from '../i18n.js';
const LANE_H = 20; // px per lane (event height 18px + 2px gap) 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 => { rowCells.forEach(cell => {
const key = dateKey(cell); const key = dateKey(cell);
const isOther = cell.getMonth() !== primaryMonth; const isOther = cell.getMonth() !== primaryMonth;
const todayCls = isToday(cell) ? 'today' : ''; const todayCls = isToday(cell) ? 'today' : '';
const otherCls = isOther ? 'other-month' : ''; const otherCls = isOther ? 'other-month' : '';
const numCls = isToday(cell) ? 'today' : ''; const selectedCls = isSameDay(cell, currentDate) ? 'month-selected' : '';
colsHtml += `<div class="month-col ${todayCls} ${otherCls}" data-date="${key}"> const numCls = isToday(cell) ? 'today' : '';
colsHtml += `<div class="month-col ${todayCls} ${otherCls} ${selectedCls}" data-date="${key}">
<div class="cell-day ${numCls}">${cell.getDate()}</div> <div class="cell-day ${numCls}">${cell.getDate()}</div>
</div>`; </div>`;
}); });
@@ -148,6 +149,8 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
// Click handlers via event delegation on the body // Click handlers via event delegation on the body
const body = container.querySelector('.month-body'); const body = container.querySelector('.month-body');
// Single click: select day (or handle event / more clicks)
body.addEventListener('click', e => { body.addEventListener('click', e => {
// Span event click // Span event click
const spanEl = e.target.closest('.month-span-event'); 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'); const moreEl = e.target.closest('.month-more');
if (moreEl) { if (moreEl) {
e.stopPropagation(); e.stopPropagation();
onDayClick(new Date(moreEl.dataset.date + 'T00:00:00')); onDayClick(new Date(moreEl.dataset.date + 'T00:00:00'), 'navigate');
return; return;
} }
// Column click → navigate to day view // Column click → select day
const colEl = e.target.closest('.month-col'); const colEl = e.target.closest('.month-col');
if (colEl) { 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);
} }
}); });
} }

View File

@@ -10,3 +10,4 @@ requests==2.32.3
pyotp==2.9.0 pyotp==2.9.0
qrcode[pil]==8.0 qrcode[pil]==8.0
Pillow==11.0.0 Pillow==11.0.0
python-dateutil==2.9.0