Einige kleine verbesserungen #1
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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}"?',
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user