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
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)

View File

@@ -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()

View File

@@ -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")

View File

@@ -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:

View File

@@ -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}

View File

@@ -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;

View File

@@ -238,6 +238,56 @@
</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">
<label>Kalender</label>
<select id="ev-calendar"></select>

View File

@@ -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 = `<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 ───────────────────────────────────────────
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');

View File

@@ -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}"?',

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';
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 += `<div class="month-col ${todayCls} ${otherCls}" data-date="${key}">
const todayCls = isToday(cell) ? 'today' : '';
const otherCls = isOther ? 'other-month' : '';
const selectedCls = isSameDay(cell, currentDate) ? 'month-selected' : '';
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>`;
});
@@ -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);
}
});
}

View File

@@ -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