diff --git a/backend/main.py b/backend/main.py index 8b4487a..9092c6f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -12,7 +12,7 @@ from sqlalchemy import text sys.path.insert(0, str(Path(__file__).parent)) from database import Base, engine -from routers import auth_router, caldav_router, profile_router, settings_router, users_router +from routers import auth_router, caldav_router, ical_router, local_router, profile_router, settings_router, users_router logging.basicConfig(level=logging.INFO) @@ -41,6 +41,8 @@ app.include_router(users_router.router, prefix="/api/users", tags=["users"]) app.include_router(caldav_router.router, prefix="/api/caldav", tags=["caldav"]) app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"]) app.include_router(profile_router.router, prefix="/api/profile", tags=["profile"]) +app.include_router(local_router.router, prefix="/api/local", tags=["local"]) +app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"]) FRONTEND_DIR = Path(__file__).parent.parent / "frontend" app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static") diff --git a/backend/models.py b/backend/models.py index d292454..9f69af0 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, Boolean, ForeignKey +from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text from sqlalchemy.orm import relationship from database import Base @@ -21,6 +21,12 @@ class User(Base): settings = relationship( "UserSettings", back_populates="user", uselist=False, cascade="all, delete-orphan" ) + local_calendars = relationship( + "LocalCalendar", back_populates="user", cascade="all, delete-orphan" + ) + ical_subscriptions = relationship( + "ICalSubscription", back_populates="user", cascade="all, delete-orphan" + ) class CalDAVAccount(Base): @@ -67,3 +73,68 @@ class UserSettings(Base): dim_past_events = Column(Boolean, default=False) user = relationship("User", back_populates="settings") + + +class LocalCalendar(Base): + __tablename__ = "local_calendars" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + name = Column(String(100), nullable=False) + color = Column(String(7), default="#34a853") + enabled = Column(Boolean, default=True) + + user = relationship("User", back_populates="local_calendars") + events = relationship("LocalEvent", back_populates="calendar", cascade="all, delete-orphan") + + +class LocalEvent(Base): + __tablename__ = "local_events" + + id = Column(Integer, primary_key=True, index=True) + calendar_id = Column(Integer, ForeignKey("local_calendars.id"), nullable=False) + uid = Column(String(255), nullable=False, unique=True) + title = Column(String(255), nullable=False) + start = Column(String(50), nullable=False) + end = Column(String(50), nullable=False) + all_day = Column(Boolean, default=False) + location = Column(String(500), nullable=True) + description = Column(Text, nullable=True) + color = Column(String(7), nullable=True) + + calendar = relationship("LocalCalendar", back_populates="events") + + +class ICalSubscription(Base): + __tablename__ = "ical_subscriptions" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + name = Column(String(100), nullable=False) + url = Column(String(1000), nullable=False) + color = Column(String(7), default="#46bdc6") + enabled = Column(Boolean, default=True) + refresh_minutes = Column(Integer, default=60) + last_fetched = Column(DateTime, nullable=True) + cached_ics = Column(Text, nullable=True) + + user = relationship("User", back_populates="ical_subscriptions") + overrides = relationship("ICalOverride", back_populates="subscription", cascade="all, delete-orphan") + + +class ICalOverride(Base): + __tablename__ = "ical_overrides" + + id = Column(Integer, primary_key=True, index=True) + subscription_id = Column(Integer, ForeignKey("ical_subscriptions.id"), nullable=False) + event_uid = Column(String(500), nullable=False) + hidden = Column(Boolean, default=False) + title = Column(String(255), nullable=True) + start = Column(String(50), nullable=True) + end = Column(String(50), nullable=True) + all_day = Column(Boolean, nullable=True) + location = Column(String(500), nullable=True) + description = Column(Text, nullable=True) + color = Column(String(7), nullable=True) + + subscription = relationship("ICalSubscription", back_populates="overrides") diff --git a/backend/routers/caldav_router.py b/backend/routers/caldav_router.py index 268f575..f8a0a60 100644 --- a/backend/routers/caldav_router.py +++ b/backend/routers/caldav_router.py @@ -9,6 +9,7 @@ import caldav_client import models from auth import get_current_user from database import get_db +from routers.ical_router import _refresh_if_needed, get_events_for_subscription logger = logging.getLogger(__name__) router = APIRouter() @@ -283,6 +284,58 @@ def get_events( "Error fetching calendar %s: %s", calendar.id, exc ) + # ── Local calendar events ───────────────────────────── + local_calendars = ( + db.query(models.LocalCalendar) + .filter( + models.LocalCalendar.user_id == current_user.id, + models.LocalCalendar.enabled == True, + ) + .all() + ) + for local_cal in local_calendars: + local_events = ( + db.query(models.LocalEvent) + .filter( + models.LocalEvent.calendar_id == local_cal.id, + models.LocalEvent.start < end, + models.LocalEvent.end > start, + ) + .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", + }) + + # ── iCal subscription events ────────────────────────── + ical_subs = ( + db.query(models.ICalSubscription) + .filter( + models.ICalSubscription.user_id == current_user.id, + models.ICalSubscription.enabled == True, + ) + .all() + ) + for sub in ical_subs: + try: + _refresh_if_needed(sub, db) + all_events.extend(get_events_for_subscription(sub, start_dt, end_dt, db)) + except Exception as exc: + logger.error("Error fetching iCal subscription %s: %s", sub.id, exc) + return all_events diff --git a/backend/routers/ical_router.py b/backend/routers/ical_router.py new file mode 100644 index 0000000..ca9704f --- /dev/null +++ b/backend/routers/ical_router.py @@ -0,0 +1,359 @@ +import logging +from datetime import datetime, timezone +from typing import Optional + +import requests as http_requests +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.orm import Session + +import models +from auth import get_current_user +from caldav_client import _parse_ics +from database import get_db + +logger = logging.getLogger(__name__) +router = APIRouter() + + +class SubscriptionCreate(BaseModel): + name: str + url: str + color: str = "#46bdc6" + refresh_minutes: int = 60 + + +class SubscriptionUpdate(BaseModel): + name: Optional[str] = None + url: Optional[str] = None + color: Optional[str] = None + enabled: Optional[bool] = None + refresh_minutes: Optional[int] = None + + +def _sub_dict(sub: models.ICalSubscription) -> dict: + return { + "id": sub.id, + "name": sub.name, + "url": sub.url, + "color": sub.color, + "enabled": sub.enabled, + "refresh_minutes": sub.refresh_minutes, + "last_fetched": sub.last_fetched.isoformat() if sub.last_fetched else None, + } + + +def _fetch_ics(url: str) -> str: + """Download .ics content from a URL.""" + try: + resp = http_requests.get(url, timeout=30, allow_redirects=True) + resp.raise_for_status() + return resp.text + except http_requests.RequestException as e: + raise ValueError(f"Fehler beim Abrufen der URL: {e}") + + +def _refresh_if_needed(sub: models.ICalSubscription, db: Session, force: bool = False): + """Refresh cached ICS data if stale or forced.""" + now = datetime.now(timezone.utc) + if ( + not force + and sub.cached_ics + and sub.last_fetched + and (now - sub.last_fetched.replace(tzinfo=timezone.utc)).total_seconds() + < sub.refresh_minutes * 60 + ): + return # Cache still fresh + + try: + sub.cached_ics = _fetch_ics(sub.url) + sub.last_fetched = now + db.commit() + except ValueError: + logger.warning("Could not refresh iCal subscription %s", sub.id) + if not sub.cached_ics: + raise + + +def get_events_for_subscription( + sub: models.ICalSubscription, start_dt: datetime, end_dt: datetime, db: Session = None +) -> list: + """Parse cached ICS and filter events within the date range, applying local overrides.""" + if not sub.cached_ics: + return [] + + # Load overrides for this subscription + overrides = {} + if db: + for ov in ( + db.query(models.ICalOverride) + .filter(models.ICalOverride.subscription_id == sub.id) + .all() + ): + overrides[ov.event_uid] = ov + + all_events = _parse_ics(sub.cached_ics, f"ical://{sub.id}") + filtered = [] + for ev in all_events: + uid = ev["id"] + + # Skip hidden (deleted) events + ov = overrides.get(uid) + if ov and ov.hidden: + continue + + # Apply overrides + if ov: + if ov.title is not None: + ev["title"] = ov.title + if ov.start is not None: + ev["start"] = ov.start + if ov.end is not None: + ev["end"] = ov.end + if ov.all_day is not None: + ev["allDay"] = ov.all_day + if ov.location is not None: + ev["location"] = ov.location + if ov.description is not None: + ev["description"] = ov.description + if ov.color is not None: + ev["color"] = ov.color + + try: + ev_start = ev["start"] + ev_end = ev["end"] + if ev["allDay"]: + if ev_end > start_dt.strftime("%Y-%m-%d") and ev_start < end_dt.strftime("%Y-%m-%d"): + filtered.append(ev) + else: + ev_s = datetime.fromisoformat(ev_start.replace("Z", "+00:00")) + ev_e = datetime.fromisoformat(ev_end.replace("Z", "+00:00")) + if ev_s.tzinfo is None: + ev_s = ev_s.replace(tzinfo=timezone.utc) + if ev_e.tzinfo is None: + ev_e = ev_e.replace(tzinfo=timezone.utc) + if ev_e > start_dt and ev_s < end_dt: + filtered.append(ev) + except Exception: + filtered.append(ev) + + for ev in filtered: + ev["calendar_id"] = f"ical-{sub.id}" + ev["calendar_name"] = sub.name + ev["calendarColor"] = sub.color + ev["source"] = "ical" + + return filtered + + +# ── Subscription CRUD ───────────────────────────────────── + +@router.get("/subscriptions") +def list_subscriptions( + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + subs = ( + db.query(models.ICalSubscription) + .filter(models.ICalSubscription.user_id == current_user.id) + .all() + ) + return [_sub_dict(s) for s in subs] + + +@router.post("/subscriptions") +def create_subscription( + data: SubscriptionCreate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + # Validate URL by fetching it + try: + ics_content = _fetch_ics(data.url) + except ValueError as e: + raise HTTPException(400, str(e)) + + sub = models.ICalSubscription( + user_id=current_user.id, + name=data.name, + url=data.url, + color=data.color, + refresh_minutes=data.refresh_minutes, + cached_ics=ics_content, + last_fetched=datetime.now(timezone.utc), + ) + db.add(sub) + db.commit() + db.refresh(sub) + return _sub_dict(sub) + + +@router.put("/subscriptions/{sub_id}") +def update_subscription( + sub_id: int, + data: SubscriptionUpdate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + sub = ( + db.query(models.ICalSubscription) + .filter( + models.ICalSubscription.id == sub_id, + models.ICalSubscription.user_id == current_user.id, + ) + .first() + ) + if not sub: + raise HTTPException(404, "Subscription not found") + if data.name is not None: + sub.name = data.name + if data.url is not None: + sub.url = data.url + if data.color is not None: + sub.color = data.color + if data.enabled is not None: + sub.enabled = data.enabled + if data.refresh_minutes is not None: + sub.refresh_minutes = data.refresh_minutes + db.commit() + return {"ok": True} + + +@router.delete("/subscriptions/{sub_id}") +def delete_subscription( + sub_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + sub = ( + db.query(models.ICalSubscription) + .filter( + models.ICalSubscription.id == sub_id, + models.ICalSubscription.user_id == current_user.id, + ) + .first() + ) + if not sub: + raise HTTPException(404, "Subscription not found") + db.delete(sub) + db.commit() + return {"ok": True} + + +@router.post("/subscriptions/{sub_id}/refresh") +def refresh_subscription( + sub_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + sub = ( + db.query(models.ICalSubscription) + .filter( + models.ICalSubscription.id == sub_id, + models.ICalSubscription.user_id == current_user.id, + ) + .first() + ) + if not sub: + raise HTTPException(404, "Subscription not found") + try: + _refresh_if_needed(sub, db, force=True) + except ValueError as e: + raise HTTPException(400, str(e)) + return _sub_dict(sub) + + +# ── Event overrides (edit/delete iCal events locally) ───── + +class ICalEventUpdate(BaseModel): + title: Optional[str] = None + start: Optional[str] = None + end: Optional[str] = None + allDay: Optional[bool] = None + location: Optional[str] = None + description: Optional[str] = None + color: Optional[str] = None + + +@router.put("/events/{sub_id}/{event_uid}") +def update_ical_event( + sub_id: int, + event_uid: str, + data: ICalEventUpdate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + sub = ( + db.query(models.ICalSubscription) + .filter( + models.ICalSubscription.id == sub_id, + models.ICalSubscription.user_id == current_user.id, + ) + .first() + ) + if not sub: + raise HTTPException(404, "Subscription not found") + + ov = ( + db.query(models.ICalOverride) + .filter( + models.ICalOverride.subscription_id == sub_id, + models.ICalOverride.event_uid == event_uid, + ) + .first() + ) + if not ov: + ov = models.ICalOverride(subscription_id=sub_id, event_uid=event_uid) + db.add(ov) + + if data.title is not None: + ov.title = data.title + if data.start is not None: + ov.start = data.start + if data.end is not None: + ov.end = data.end + if data.allDay is not None: + ov.all_day = data.allDay + if data.location is not None: + ov.location = data.location + if data.description is not None: + ov.description = data.description + if data.color is not None: + ov.color = data.color + db.commit() + return {"ok": True} + + +@router.delete("/events/{sub_id}/{event_uid}") +def delete_ical_event( + sub_id: int, + event_uid: str, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + sub = ( + db.query(models.ICalSubscription) + .filter( + models.ICalSubscription.id == sub_id, + models.ICalSubscription.user_id == current_user.id, + ) + .first() + ) + if not sub: + raise HTTPException(404, "Subscription not found") + + ov = ( + db.query(models.ICalOverride) + .filter( + models.ICalOverride.subscription_id == sub_id, + models.ICalOverride.event_uid == event_uid, + ) + .first() + ) + if not ov: + ov = models.ICalOverride(subscription_id=sub_id, event_uid=event_uid, hidden=True) + db.add(ov) + else: + ov.hidden = True + db.commit() + return {"ok": True} diff --git a/backend/routers/local_router.py b/backend/routers/local_router.py new file mode 100644 index 0000000..405dd52 --- /dev/null +++ b/backend/routers/local_router.py @@ -0,0 +1,245 @@ +import uuid +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.orm import Session + +import models +from auth import get_current_user +from database import get_db + +router = APIRouter() + + +class CalendarCreate(BaseModel): + name: str + color: str = "#34a853" + + +class CalendarUpdate(BaseModel): + name: Optional[str] = None + color: Optional[str] = None + enabled: Optional[bool] = None + + +class EventCreate(BaseModel): + calendar_id: int + title: str + start: str + end: str + allDay: bool = False + location: Optional[str] = None + description: Optional[str] = None + color: Optional[str] = None + + +class EventUpdate(BaseModel): + title: Optional[str] = None + start: Optional[str] = None + end: Optional[str] = None + allDay: Optional[bool] = None + location: Optional[str] = None + description: Optional[str] = None + color: Optional[str] = None + + +def _cal_dict(cal: models.LocalCalendar) -> dict: + return { + "id": cal.id, + "name": cal.name, + "color": cal.color, + "enabled": cal.enabled, + } + + +def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar) -> dict: + return { + "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-{cal.id}", + "calendar_name": cal.name, + "calendarColor": cal.color, + "source": "local", + } + + +# ── Calendar CRUD ───────────────────────────────────────── + +@router.get("/calendars") +def list_calendars( + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + cals = ( + db.query(models.LocalCalendar) + .filter(models.LocalCalendar.user_id == current_user.id) + .all() + ) + return [_cal_dict(c) for c in cals] + + +@router.post("/calendars") +def create_calendar( + data: CalendarCreate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + cal = models.LocalCalendar( + user_id=current_user.id, + name=data.name, + color=data.color, + ) + db.add(cal) + db.commit() + db.refresh(cal) + return _cal_dict(cal) + + +@router.put("/calendars/{calendar_id}") +def update_calendar( + calendar_id: int, + data: CalendarUpdate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + cal = ( + db.query(models.LocalCalendar) + .filter( + models.LocalCalendar.id == calendar_id, + models.LocalCalendar.user_id == current_user.id, + ) + .first() + ) + if not cal: + raise HTTPException(404, "Calendar not found") + if data.name is not None: + cal.name = data.name + if data.color is not None: + cal.color = data.color + if data.enabled is not None: + cal.enabled = data.enabled + db.commit() + return {"ok": True} + + +@router.delete("/calendars/{calendar_id}") +def delete_calendar( + calendar_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + cal = ( + db.query(models.LocalCalendar) + .filter( + models.LocalCalendar.id == calendar_id, + models.LocalCalendar.user_id == current_user.id, + ) + .first() + ) + if not cal: + raise HTTPException(404, "Calendar not found") + db.delete(cal) + db.commit() + return {"ok": True} + + +# ── Event CRUD ──────────────────────────────────────────── + +@router.post("/events") +def create_event( + data: EventCreate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + cal = ( + db.query(models.LocalCalendar) + .filter( + models.LocalCalendar.id == data.calendar_id, + models.LocalCalendar.user_id == current_user.id, + ) + .first() + ) + if not cal: + raise HTTPException(404, "Calendar not found") + + ev = models.LocalEvent( + calendar_id=cal.id, + uid=str(uuid.uuid4()), + title=data.title, + start=data.start, + end=data.end, + all_day=data.allDay, + location=data.location, + description=data.description, + color=data.color, + ) + db.add(ev) + db.commit() + db.refresh(ev) + return _event_dict(ev, cal) + + +@router.put("/events/{uid}") +def update_event( + uid: str, + data: EventUpdate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + ev = ( + db.query(models.LocalEvent) + .join(models.LocalCalendar) + .filter( + models.LocalEvent.uid == uid, + models.LocalCalendar.user_id == current_user.id, + ) + .first() + ) + if not ev: + raise HTTPException(404, "Event not found") + if data.title is not None: + ev.title = data.title + if data.start is not None: + ev.start = data.start + if data.end is not None: + ev.end = data.end + if data.allDay is not None: + ev.all_day = data.allDay + if data.location is not None: + ev.location = data.location + if data.description is not None: + ev.description = data.description + if data.color is not None: + ev.color = data.color + db.commit() + return {"ok": True} + + +@router.delete("/events/{uid}") +def delete_event( + uid: str, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + ev = ( + db.query(models.LocalEvent) + .join(models.LocalCalendar) + .filter( + models.LocalEvent.uid == uid, + models.LocalCalendar.user_id == current_user.id, + ) + .first() + ) + if not ev: + raise HTTPException(404, "Event not found") + db.delete(ev) + db.commit() + return {"ok": True} diff --git a/frontend/css/app.css b/frontend/css/app.css index 8fbcd34..4d4c6bf 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -379,6 +379,19 @@ a { color: var(--primary); text-decoration: none; } font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .5px; color: var(--text-2); } +.add-cal-dropdown-wrap { position: relative; } +.add-cal-dropdown { + position: absolute; top: 100%; right: 0; z-index: 200; + min-width: 180px; padding: 4px 0; + background: var(--bg-surface); border: 1px solid var(--border); + border-radius: var(--radius); box-shadow: var(--shadow-lg); +} +.add-cal-dropdown button { + display: block; width: 100%; padding: 8px 14px; + text-align: left; font-size: 13px; color: var(--text-1); + background: none; border: none; cursor: pointer; +} +.add-cal-dropdown button:hover { background: var(--bg-hover); } .cal-item { display: flex; align-items: center; gap: 10px; padding: 6px 16px; cursor: pointer; diff --git a/frontend/index.html b/frontend/index.html index 8dde17e..dd7e419 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -151,9 +151,16 @@