feat: Home Assistant Kalender-Integration + Bugfix ausgeblendete Kalender
- Neue Integration: Home Assistant als Kalenderquelle via REST-API
(GET /api/calendars + GET /api/calendars/{entity_id})
- Authentifizierung per Long-Lived Access Token
- Neues Modal zum Verbinden (Name, URL, Token) mit Fehlerbehandlung
- Kalender einzeln aktivierbar/deaktivierbar, Farbe änderbar
- Ausgeblendete HA-Kalender in Einstellungen wiederherstellbar
- Sync- und Trennen-Buttons in den Einstellungen
- Bugfix: CalDAV- und Google-Kalender mit sidebar_hidden=true
liefern nun keine Events mehr im Kalender
This commit is contained in:
@@ -12,7 +12,7 @@ from sqlalchemy import text
|
|||||||
sys.path.insert(0, str(Path(__file__).parent))
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
from database import Base, engine
|
from database import Base, engine
|
||||||
from routers import auth_router, caldav_router, google_router, ical_router, local_router, profile_router, settings_router, users_router
|
from routers import auth_router, caldav_router, google_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
@@ -76,6 +76,7 @@ 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(local_router.router, prefix="/api/local", tags=["local"])
|
||||||
app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"])
|
app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"])
|
||||||
app.include_router(google_router.router, prefix="/api/google", tags=["google"])
|
app.include_router(google_router.router, prefix="/api/google", tags=["google"])
|
||||||
|
app.include_router(homeassistant_router.router, prefix="/api/homeassistant", tags=["homeassistant"])
|
||||||
|
|
||||||
FRONTEND_DIR = Path(__file__).parent.parent / "frontend"
|
FRONTEND_DIR = Path(__file__).parent.parent / "frontend"
|
||||||
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
|
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ class User(Base):
|
|||||||
google_accounts = relationship(
|
google_accounts = relationship(
|
||||||
"GoogleAccount", back_populates="user", cascade="all, delete-orphan"
|
"GoogleAccount", back_populates="user", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
homeassistant_accounts = relationship(
|
||||||
|
"HomeAssistantAccount", back_populates="user", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CalDAVAccount(Base):
|
class CalDAVAccount(Base):
|
||||||
@@ -176,3 +179,32 @@ class GoogleCalendar(Base):
|
|||||||
sidebar_hidden = Column(Boolean, default=False)
|
sidebar_hidden = Column(Boolean, default=False)
|
||||||
|
|
||||||
account = relationship("GoogleAccount", back_populates="calendars")
|
account = relationship("GoogleAccount", back_populates="calendars")
|
||||||
|
|
||||||
|
|
||||||
|
class HomeAssistantAccount(Base):
|
||||||
|
__tablename__ = "homeassistant_accounts"
|
||||||
|
|
||||||
|
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(500), nullable=False)
|
||||||
|
token = Column(Text, nullable=False)
|
||||||
|
|
||||||
|
user = relationship("User", back_populates="homeassistant_accounts")
|
||||||
|
calendars = relationship(
|
||||||
|
"HomeAssistantCalendar", back_populates="account", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HomeAssistantCalendar(Base):
|
||||||
|
__tablename__ = "homeassistant_calendars"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
account_id = Column(Integer, ForeignKey("homeassistant_accounts.id"), nullable=False)
|
||||||
|
entity_id = Column(String(255), nullable=False)
|
||||||
|
name = Column(String(255), nullable=False)
|
||||||
|
color = Column(String(7), nullable=True)
|
||||||
|
enabled = Column(Boolean, default=True)
|
||||||
|
sidebar_hidden = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
account = relationship("HomeAssistantAccount", back_populates="calendars")
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ def get_events(
|
|||||||
|
|
||||||
for account in accounts:
|
for account in accounts:
|
||||||
for calendar in account.calendars:
|
for calendar in account.calendars:
|
||||||
if not calendar.enabled:
|
if not calendar.enabled or calendar.sidebar_hidden:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
events = caldav_client.fetch_events(
|
events = caldav_client.fetch_events(
|
||||||
@@ -355,6 +355,19 @@ def get_events(
|
|||||||
logger.error("Error fetching Google Calendar for %s: %s", g_acc.email, exc)
|
logger.error("Error fetching Google Calendar for %s: %s", g_acc.email, exc)
|
||||||
google_errors.append({"email": g_acc.email})
|
google_errors.append({"email": g_acc.email})
|
||||||
|
|
||||||
|
# ── Home Assistant events ─────────────────────────────
|
||||||
|
from routers.homeassistant_router import get_ha_events
|
||||||
|
ha_accounts = (
|
||||||
|
db.query(models.HomeAssistantAccount)
|
||||||
|
.filter(models.HomeAssistantAccount.user_id == current_user.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for ha_acc in ha_accounts:
|
||||||
|
try:
|
||||||
|
all_events.extend(get_ha_events(ha_acc, start_dt, end_dt))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Error fetching HA events for %s: %s", ha_acc.name, exc)
|
||||||
|
|
||||||
return {"events": all_events, "errors": google_errors}
|
return {"events": all_events, "errors": google_errors}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -388,7 +388,7 @@ def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt:
|
|||||||
|
|
||||||
all_events = []
|
all_events = []
|
||||||
for gcal in account.calendars:
|
for gcal in account.calendars:
|
||||||
if not gcal.enabled:
|
if not gcal.enabled or gcal.sidebar_hidden:
|
||||||
continue
|
continue
|
||||||
if _is_system_calendar(gcal.cal_id):
|
if _is_system_calendar(gcal.cal_id):
|
||||||
continue
|
continue
|
||||||
|
|||||||
268
backend/routers/homeassistant_router.py
Normal file
268
backend/routers/homeassistant_router.py
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
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 database import get_db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
HA_DEFAULT_COLOR = "#03a9f4"
|
||||||
|
|
||||||
|
|
||||||
|
# ── HA API helpers ────────────────────────────────────────
|
||||||
|
|
||||||
|
def _ha_get_calendars(url: str, token: str) -> list:
|
||||||
|
try:
|
||||||
|
resp = http_requests.get(
|
||||||
|
f"{url.rstrip('/')}/api/calendars",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
timeout=10,
|
||||||
|
verify=False,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
except http_requests.exceptions.ConnectionError:
|
||||||
|
raise HTTPException(503, "Home Assistant nicht erreichbar")
|
||||||
|
except http_requests.exceptions.Timeout:
|
||||||
|
raise HTTPException(503, "Home Assistant antwortet nicht (Timeout)")
|
||||||
|
except http_requests.exceptions.HTTPError as e:
|
||||||
|
if e.response is not None and e.response.status_code == 401:
|
||||||
|
raise HTTPException(401, "Ungültiger Access Token")
|
||||||
|
raise HTTPException(502, f"Home Assistant Fehler: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _ha_get_events(url: str, token: str, entity_id: str, start_dt: datetime, end_dt: datetime) -> list:
|
||||||
|
try:
|
||||||
|
resp = http_requests.get(
|
||||||
|
f"{url.rstrip('/')}/api/calendars/{entity_id}",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
params={"start": start_dt.isoformat(), "end": end_dt.isoformat()},
|
||||||
|
timeout=15,
|
||||||
|
verify=False,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
except http_requests.exceptions.ConnectionError:
|
||||||
|
raise http_requests.exceptions.ConnectionError(f"HA nicht erreichbar für {entity_id}")
|
||||||
|
except http_requests.exceptions.Timeout:
|
||||||
|
raise http_requests.exceptions.Timeout(f"HA Timeout für {entity_id}")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ha_event(ev: dict, cal_db_id: int, cal_name: str, cal_color: str) -> dict:
|
||||||
|
start = ev.get("start", {})
|
||||||
|
end = ev.get("end", {})
|
||||||
|
all_day = "date" in start and "dateTime" not in start
|
||||||
|
return {
|
||||||
|
"id": ev.get("uid") or f"ha-{cal_db_id}-{ev.get('summary', '')}",
|
||||||
|
"url": f"homeassistant://{cal_db_id}/{ev.get('uid', '')}",
|
||||||
|
"title": ev.get("summary", "(Kein Titel)"),
|
||||||
|
"start": start.get("dateTime") or start.get("date", ""),
|
||||||
|
"end": end.get("dateTime") or end.get("date", ""),
|
||||||
|
"allDay": all_day,
|
||||||
|
"location": ev.get("location", ""),
|
||||||
|
"description": ev.get("description", ""),
|
||||||
|
"color": None,
|
||||||
|
"calendar_id": f"homeassistant-{cal_db_id}",
|
||||||
|
"calendar_name": cal_name,
|
||||||
|
"calendarColor": cal_color,
|
||||||
|
"source": "homeassistant",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_ha_events(account: models.HomeAssistantAccount, start_dt: datetime, end_dt: datetime) -> list:
|
||||||
|
all_events = []
|
||||||
|
for cal in account.calendars:
|
||||||
|
if not cal.enabled or cal.sidebar_hidden:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
raw = _ha_get_events(account.url, account.token, cal.entity_id, start_dt, end_dt)
|
||||||
|
color = cal.color or HA_DEFAULT_COLOR
|
||||||
|
for ev in raw:
|
||||||
|
all_events.append(_parse_ha_event(ev, cal.id, cal.name, color))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("HA event fetch error %s (%s): %s", cal.entity_id, account.name, exc)
|
||||||
|
return all_events
|
||||||
|
|
||||||
|
|
||||||
|
# ── Serialization ─────────────────────────────────────────
|
||||||
|
|
||||||
|
def _account_dict(a: models.HomeAssistantAccount) -> dict:
|
||||||
|
return {
|
||||||
|
"id": a.id,
|
||||||
|
"name": a.name,
|
||||||
|
"url": a.url,
|
||||||
|
"calendars": [
|
||||||
|
{
|
||||||
|
"id": c.id,
|
||||||
|
"name": c.name,
|
||||||
|
"entity_id": c.entity_id,
|
||||||
|
"color": c.color or HA_DEFAULT_COLOR,
|
||||||
|
"enabled": c.enabled,
|
||||||
|
"sidebar_hidden": bool(c.sidebar_hidden),
|
||||||
|
}
|
||||||
|
for c in a.calendars
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pydantic models ───────────────────────────────────────
|
||||||
|
|
||||||
|
class HAAccountCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
url: str
|
||||||
|
token: str
|
||||||
|
|
||||||
|
|
||||||
|
class HACalendarUpdate(BaseModel):
|
||||||
|
enabled: Optional[bool] = None
|
||||||
|
color: Optional[str] = None
|
||||||
|
name: Optional[str] = None
|
||||||
|
sidebar_hidden: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Endpoints ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/accounts")
|
||||||
|
def list_accounts(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
accounts = (
|
||||||
|
db.query(models.HomeAssistantAccount)
|
||||||
|
.filter(models.HomeAssistantAccount.user_id == current_user.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [_account_dict(a) for a in accounts]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/accounts")
|
||||||
|
def add_account(
|
||||||
|
data: HAAccountCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
remote_cals = _ha_get_calendars(data.url, data.token)
|
||||||
|
|
||||||
|
account = models.HomeAssistantAccount(
|
||||||
|
user_id=current_user.id,
|
||||||
|
name=data.name,
|
||||||
|
url=data.url,
|
||||||
|
token=data.token,
|
||||||
|
)
|
||||||
|
db.add(account)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
for cal in remote_cals:
|
||||||
|
entity_id = cal.get("entity_id", "")
|
||||||
|
if not entity_id:
|
||||||
|
continue
|
||||||
|
db.add(models.HomeAssistantCalendar(
|
||||||
|
account_id=account.id,
|
||||||
|
entity_id=entity_id,
|
||||||
|
name=cal.get("name") or entity_id,
|
||||||
|
color=None,
|
||||||
|
enabled=True,
|
||||||
|
))
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(account)
|
||||||
|
return _account_dict(account)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/accounts/{account_id}")
|
||||||
|
def delete_account(
|
||||||
|
account_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
acc = (
|
||||||
|
db.query(models.HomeAssistantAccount)
|
||||||
|
.filter(
|
||||||
|
models.HomeAssistantAccount.id == account_id,
|
||||||
|
models.HomeAssistantAccount.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not acc:
|
||||||
|
raise HTTPException(404, "Account not found")
|
||||||
|
db.delete(acc)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/accounts/{account_id}/sync")
|
||||||
|
def sync_account(
|
||||||
|
account_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
acc = (
|
||||||
|
db.query(models.HomeAssistantAccount)
|
||||||
|
.filter(
|
||||||
|
models.HomeAssistantAccount.id == account_id,
|
||||||
|
models.HomeAssistantAccount.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not acc:
|
||||||
|
raise HTTPException(404, "Account not found")
|
||||||
|
|
||||||
|
remote_cals = _ha_get_calendars(acc.url, acc.token)
|
||||||
|
existing = {c.entity_id: c for c in acc.calendars}
|
||||||
|
|
||||||
|
for cal in remote_cals:
|
||||||
|
entity_id = cal.get("entity_id", "")
|
||||||
|
if not entity_id:
|
||||||
|
continue
|
||||||
|
if entity_id not in existing:
|
||||||
|
db.add(models.HomeAssistantCalendar(
|
||||||
|
account_id=acc.id,
|
||||||
|
entity_id=entity_id,
|
||||||
|
name=cal.get("name") or entity_id,
|
||||||
|
color=None,
|
||||||
|
enabled=True,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
existing[entity_id].name = cal.get("name") or entity_id
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(acc)
|
||||||
|
return _account_dict(acc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/calendars/{calendar_id}")
|
||||||
|
def update_calendar(
|
||||||
|
calendar_id: int,
|
||||||
|
data: HACalendarUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
cal = (
|
||||||
|
db.query(models.HomeAssistantCalendar)
|
||||||
|
.join(models.HomeAssistantAccount)
|
||||||
|
.filter(
|
||||||
|
models.HomeAssistantCalendar.id == calendar_id,
|
||||||
|
models.HomeAssistantAccount.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not cal:
|
||||||
|
raise HTTPException(404, "Calendar not found")
|
||||||
|
if data.enabled is not None:
|
||||||
|
cal.enabled = data.enabled
|
||||||
|
if data.color is not None:
|
||||||
|
cal.color = data.color
|
||||||
|
if data.name is not None:
|
||||||
|
cal.name = data.name
|
||||||
|
if data.sidebar_hidden is not None:
|
||||||
|
cal.sidebar_hidden = data.sidebar_hidden
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
@@ -165,6 +165,7 @@
|
|||||||
<button data-action="caldav">CalDAV-Konto</button>
|
<button data-action="caldav">CalDAV-Konto</button>
|
||||||
<button data-action="ical">iCal-URL abonnieren</button>
|
<button data-action="ical">iCal-URL abonnieren</button>
|
||||||
<button data-action="google">Google Kalender</button>
|
<button data-action="google">Google Kalender</button>
|
||||||
|
<button data-action="homeassistant">Home Assistant</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -399,6 +400,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Home Assistant Account Modal -->
|
||||||
|
<div id="modal-ha-account" class="modal-overlay hidden">
|
||||||
|
<div class="modal-card" style="max-width:480px">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Home Assistant verbinden</h3>
|
||||||
|
<button class="icon-btn modal-close" data-modal="modal-ha-account">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Anzeigename</label>
|
||||||
|
<input type="text" id="ha-account-name" placeholder="z.B. Mein Home Assistant" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Home Assistant URL</label>
|
||||||
|
<input type="url" id="ha-account-url" placeholder="http://homeassistant.local:8123" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Long-Lived Access Token</label>
|
||||||
|
<input type="password" id="ha-account-token" placeholder="Token aus Profil → Sicherheit" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div id="ha-account-error" class="form-error hidden"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-ghost" data-modal="modal-ha-account">Abbrechen</button>
|
||||||
|
<button class="btn btn-primary" id="ha-account-save">Verbinden</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Settings Page -->
|
<!-- Settings Page -->
|
||||||
<div id="modal-settings" class="modal-overlay hidden">
|
<div id="modal-settings" class="modal-overlay hidden">
|
||||||
<div class="settings-page-card">
|
<div class="settings-page-card">
|
||||||
@@ -531,6 +561,11 @@
|
|||||||
<div class="accounts-section-heading" data-i18n="settings_accounts_google">Google-Konten</div>
|
<div class="accounts-section-heading" data-i18n="settings_accounts_google">Google-Konten</div>
|
||||||
<div id="google-accounts-list"><span class="accounts-section-empty" data-i18n="settings_no_google_accounts">Keine Google-Konten</span></div>
|
<div id="google-accounts-list"><span class="accounts-section-empty" data-i18n="settings_no_google_accounts">Keine Google-Konten</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="accounts-section">
|
||||||
|
<div class="accounts-section-heading">Home Assistant</div>
|
||||||
|
<div id="accounts-ha-list"><span class="accounts-section-empty">Keine HA-Konten</span></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Benutzerverwaltung -->
|
<!-- Benutzerverwaltung -->
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ let state = {
|
|||||||
localCalendars: [],
|
localCalendars: [],
|
||||||
icalSubscriptions: [],
|
icalSubscriptions: [],
|
||||||
googleAccounts: [],
|
googleAccounts: [],
|
||||||
|
haAccounts: [],
|
||||||
settings: {},
|
settings: {},
|
||||||
dimPast: false,
|
dimPast: false,
|
||||||
editingEvent: null, // null = new event
|
editingEvent: null, // null = new event
|
||||||
@@ -40,12 +41,13 @@ let state = {
|
|||||||
|
|
||||||
// ── Public init ───────────────────────────────────────────
|
// ── Public init ───────────────────────────────────────────
|
||||||
export async function initCalendar() {
|
export async function initCalendar() {
|
||||||
const [settings, accounts, localCalendars, icalSubscriptions, googleAccounts] = await Promise.all([
|
const [settings, accounts, localCalendars, icalSubscriptions, googleAccounts, haAccounts] = await Promise.all([
|
||||||
api.get('/settings/'),
|
api.get('/settings/'),
|
||||||
api.get('/caldav/accounts'),
|
api.get('/caldav/accounts'),
|
||||||
api.get('/local/calendars').catch(() => []),
|
api.get('/local/calendars').catch(() => []),
|
||||||
api.get('/ical/subscriptions').catch(() => []),
|
api.get('/ical/subscriptions').catch(() => []),
|
||||||
api.get('/google/accounts').catch(() => []),
|
api.get('/google/accounts').catch(() => []),
|
||||||
|
api.get('/homeassistant/accounts').catch(() => []),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
state.settings = settings;
|
state.settings = settings;
|
||||||
@@ -53,6 +55,7 @@ export async function initCalendar() {
|
|||||||
state.localCalendars = localCalendars;
|
state.localCalendars = localCalendars;
|
||||||
state.icalSubscriptions = icalSubscriptions;
|
state.icalSubscriptions = icalSubscriptions;
|
||||||
state.googleAccounts = googleAccounts;
|
state.googleAccounts = googleAccounts;
|
||||||
|
state.haAccounts = haAccounts;
|
||||||
state.currentView = settings.default_view || 'month';
|
state.currentView = settings.default_view || 'month';
|
||||||
state.dimPast = settings.dim_past_events;
|
state.dimPast = settings.dim_past_events;
|
||||||
weekStartDay = settings.week_start_day || 'monday';
|
weekStartDay = settings.week_start_day || 'monday';
|
||||||
@@ -69,6 +72,7 @@ export async function initCalendar() {
|
|||||||
bindAccountModal();
|
bindAccountModal();
|
||||||
bindLocalCalModal();
|
bindLocalCalModal();
|
||||||
bindICalSubModal();
|
bindICalSubModal();
|
||||||
|
bindHAAccountModal();
|
||||||
bindSettingsModal();
|
bindSettingsModal();
|
||||||
bindProfileModal();
|
bindProfileModal();
|
||||||
}
|
}
|
||||||
@@ -449,6 +453,25 @@ function renderCalendarList() {
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Home Assistant accounts ───────────────────────────
|
||||||
|
if (state.haAccounts.length) {
|
||||||
|
html += state.haAccounts.map(acc => {
|
||||||
|
const visibleCals = acc.calendars.filter(c => !c.sidebar_hidden);
|
||||||
|
if (!visibleCals.length) return `<div class="cal-account-name">${escHtml(acc.name)}</div>`;
|
||||||
|
return `<div class="cal-account-name">${escHtml(acc.name)}</div>` +
|
||||||
|
visibleCals.map(cal =>
|
||||||
|
`<div class="cal-item" data-cal-id="${cal.id}" data-source="homeassistant">
|
||||||
|
<input type="checkbox" ${cal.enabled ? 'checked' : ''} data-cal-id="${cal.id}" data-source="homeassistant" />
|
||||||
|
<div class="cal-item-dot" style="background:${cal.color || '#03a9f4'}" data-cal-id="${cal.id}" data-source="homeassistant" title="${t('change_color')}"></div>
|
||||||
|
<span class="cal-item-name" data-source="homeassistant">${escHtml(cal.name)}</span>
|
||||||
|
<button class="icon-btn mini-btn cal-item-remove" data-cal-id="${cal.id}" data-source="homeassistant" title="${t('hide_cal')}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>`
|
||||||
|
).join('');
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
if (!html) {
|
if (!html) {
|
||||||
container.innerHTML = `<div style="padding:8px 16px;font-size:12px;color:var(--text-3)">${t('error_no_calendars')}</div>`;
|
container.innerHTML = `<div style="padding:8px 16px;font-size:12px;color:var(--text-3)">${t('error_no_calendars')}</div>`;
|
||||||
return;
|
return;
|
||||||
@@ -491,6 +514,14 @@ function renderCalendarList() {
|
|||||||
if (cal) cal.enabled = cb.checked;
|
if (cal) cal.enabled = cb.checked;
|
||||||
}
|
}
|
||||||
cacheCalId = `google-${calId}`;
|
cacheCalId = `google-${calId}`;
|
||||||
|
} else if (source === 'homeassistant') {
|
||||||
|
const calId = parseInt(cb.dataset.calId);
|
||||||
|
await api.put(`/homeassistant/calendars/${calId}`, { enabled: cb.checked });
|
||||||
|
for (const acc of state.haAccounts) {
|
||||||
|
const cal = acc.calendars.find(c => c.id === calId);
|
||||||
|
if (cal) cal.enabled = cb.checked;
|
||||||
|
}
|
||||||
|
cacheCalId = `homeassistant-${calId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!cb.checked && cacheCalId !== null) {
|
if (!cb.checked && cacheCalId !== null) {
|
||||||
@@ -545,6 +576,19 @@ function renderCalendarList() {
|
|||||||
if (gcal) gcal.color = picked;
|
if (gcal) gcal.color = picked;
|
||||||
applyCalendarColor('google', calId, picked);
|
applyCalendarColor('google', calId, picked);
|
||||||
}
|
}
|
||||||
|
} else if (source === 'homeassistant') {
|
||||||
|
const calId = parseInt(dot.dataset.calId);
|
||||||
|
let hacal = null;
|
||||||
|
for (const acc of state.haAccounts) {
|
||||||
|
hacal = acc.calendars.find(c => c.id === calId);
|
||||||
|
if (hacal) break;
|
||||||
|
}
|
||||||
|
const picked = await openColorPicker(dot, hacal?.color || '#03a9f4');
|
||||||
|
if (picked) {
|
||||||
|
await api.put(`/homeassistant/calendars/${calId}`, { color: picked });
|
||||||
|
if (hacal) hacal.color = picked;
|
||||||
|
applyCalendarColor('homeassistant', calId, picked);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -627,6 +671,14 @@ function renderCalendarList() {
|
|||||||
if (cal.id === calId) { cal.enabled = false; cal.sidebar_hidden = true; }
|
if (cal.id === calId) { cal.enabled = false; cal.sidebar_hidden = true; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (source === 'homeassistant') {
|
||||||
|
const calId = parseInt(btn.dataset.calId);
|
||||||
|
await api.put(`/homeassistant/calendars/${calId}`, { enabled: false, sidebar_hidden: true });
|
||||||
|
for (const acc of state.haAccounts) {
|
||||||
|
for (const cal of acc.calendars) {
|
||||||
|
if (cal.id === calId) { cal.enabled = false; cal.sidebar_hidden = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
renderCalendarList();
|
renderCalendarList();
|
||||||
fetchAndRender();
|
fetchAndRender();
|
||||||
@@ -727,6 +779,10 @@ function bindSidebar() {
|
|||||||
dropdown.classList.add('hidden');
|
dropdown.classList.add('hidden');
|
||||||
openICalSubModal();
|
openICalSubModal();
|
||||||
};
|
};
|
||||||
|
dropdown.querySelector('[data-action="homeassistant"]').onclick = () => {
|
||||||
|
dropdown.classList.add('hidden');
|
||||||
|
openHAAccountModal();
|
||||||
|
};
|
||||||
dropdown.querySelector('[data-action="google"]').onclick = async () => {
|
dropdown.querySelector('[data-action="google"]').onclick = async () => {
|
||||||
dropdown.classList.add('hidden');
|
dropdown.classList.add('hidden');
|
||||||
try {
|
try {
|
||||||
@@ -1193,6 +1249,48 @@ function bindLocalCalModal() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Home Assistant Account Modal ─────────────────────────
|
||||||
|
function openHAAccountModal() {
|
||||||
|
document.getElementById('ha-account-name').value = '';
|
||||||
|
document.getElementById('ha-account-url').value = '';
|
||||||
|
document.getElementById('ha-account-token').value = '';
|
||||||
|
document.getElementById('ha-account-error').classList.add('hidden');
|
||||||
|
openModal('modal-ha-account');
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindHAAccountModal() {
|
||||||
|
document.getElementById('ha-account-save').onclick = async () => {
|
||||||
|
const name = document.getElementById('ha-account-name').value.trim();
|
||||||
|
const url = document.getElementById('ha-account-url').value.trim();
|
||||||
|
const token = document.getElementById('ha-account-token').value.trim();
|
||||||
|
const errEl = document.getElementById('ha-account-error');
|
||||||
|
if (!name || !url || !token) {
|
||||||
|
errEl.textContent = 'Bitte alle Felder ausfüllen';
|
||||||
|
errEl.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
errEl.classList.add('hidden');
|
||||||
|
|
||||||
|
const saveBtn = document.getElementById('ha-account-save');
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = 'Verbinde…';
|
||||||
|
try {
|
||||||
|
const account = await api.post('/homeassistant/accounts', { name, url, token });
|
||||||
|
state.haAccounts.push(account);
|
||||||
|
renderCalendarList();
|
||||||
|
closeModal('modal-ha-account');
|
||||||
|
showToast(`Home Assistant "${name}" verbunden`);
|
||||||
|
fetchAndRender(true);
|
||||||
|
} catch (e) {
|
||||||
|
errEl.textContent = e.message || 'Home Assistant nicht erreichbar';
|
||||||
|
errEl.classList.remove('hidden');
|
||||||
|
} finally {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = 'Verbinden';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ── iCal Subscription Modal ──────────────────────────────
|
// ── iCal Subscription Modal ──────────────────────────────
|
||||||
function openICalSubModal() {
|
function openICalSubModal() {
|
||||||
document.getElementById('ical-sub-name').value = '';
|
document.getElementById('ical-sub-name').value = '';
|
||||||
@@ -1437,6 +1535,51 @@ function renderAllAccounts() {
|
|||||||
|
|
||||||
// Google accounts section — delegate to existing function
|
// Google accounts section — delegate to existing function
|
||||||
renderGoogleAccounts();
|
renderGoogleAccounts();
|
||||||
|
|
||||||
|
// Home Assistant accounts section
|
||||||
|
const haList = document.getElementById('accounts-ha-list');
|
||||||
|
if (haList) {
|
||||||
|
if (!state.haAccounts.length) {
|
||||||
|
haList.innerHTML = '<span class="accounts-section-empty">Keine HA-Konten</span>';
|
||||||
|
} else {
|
||||||
|
haList.innerHTML = state.haAccounts.map(acc =>
|
||||||
|
`<div class="accounts-row">
|
||||||
|
<div class="accounts-row-info">
|
||||||
|
<span class="accounts-row-name">${escHtml(acc.name)}</span>
|
||||||
|
<span class="accounts-row-sub">${escHtml(acc.url || '')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="accounts-row-actions">
|
||||||
|
<button class="btn btn-secondary btn-sm" data-ha-sync="${acc.id}">${t('sync')}</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" data-ha-disconnect="${acc.id}">${t('disconnect')}</button>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
).join('');
|
||||||
|
haList.querySelectorAll('[data-ha-sync]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
btn.disabled = true; btn.textContent = '…';
|
||||||
|
try {
|
||||||
|
const updated = await api.post(`/homeassistant/accounts/${btn.dataset.haSync}/sync`);
|
||||||
|
const idx = state.haAccounts.findIndex(a => a.id === parseInt(btn.dataset.haSync));
|
||||||
|
if (idx !== -1) state.haAccounts[idx] = updated;
|
||||||
|
renderAllAccounts(); renderCalendarList(); fetchAndRender(true);
|
||||||
|
showToast('Home Assistant synchronisiert');
|
||||||
|
} catch (e) { showToast(e.message, true); }
|
||||||
|
finally { btn.disabled = false; btn.textContent = t('sync'); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
haList.querySelectorAll('[data-ha-disconnect]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
if (!confirm('Home Assistant Konto wirklich trennen?')) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/homeassistant/accounts/${btn.dataset.haDisconnect}`);
|
||||||
|
state.haAccounts = state.haAccounts.filter(a => a.id !== parseInt(btn.dataset.haDisconnect));
|
||||||
|
renderAllAccounts(); renderCalendarList(); fetchAndRender(true);
|
||||||
|
showToast('Home Assistant getrennt');
|
||||||
|
} catch (e) { showToast(e.message, true); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderHiddenCalendars() {
|
function renderHiddenCalendars() {
|
||||||
@@ -1452,6 +1595,11 @@ function renderHiddenCalendars() {
|
|||||||
if (cal.sidebar_hidden) hidden.push({ id: cal.id, name: cal.name, acc: acc.email, source: 'google' });
|
if (cal.sidebar_hidden) hidden.push({ id: cal.id, name: cal.name, acc: acc.email, source: 'google' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const acc of state.haAccounts) {
|
||||||
|
for (const cal of acc.calendars) {
|
||||||
|
if (cal.sidebar_hidden) hidden.push({ id: cal.id, name: cal.name, acc: acc.name, source: 'homeassistant' });
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!hidden.length) {
|
if (!hidden.length) {
|
||||||
list.innerHTML = `<span style="font-size:13px;color:var(--text-3)">${t('settings_no_hidden_cals')}</span>`;
|
list.innerHTML = `<span style="font-size:13px;color:var(--text-3)">${t('settings_no_hidden_cals')}</span>`;
|
||||||
return;
|
return;
|
||||||
@@ -1473,6 +1621,13 @@ function renderHiddenCalendars() {
|
|||||||
if (cal.id === calId) { cal.enabled = true; cal.sidebar_hidden = false; }
|
if (cal.id === calId) { cal.enabled = true; cal.sidebar_hidden = false; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (source === 'homeassistant') {
|
||||||
|
await api.put(`/homeassistant/calendars/${calId}`, { enabled: true, sidebar_hidden: false });
|
||||||
|
for (const acc of state.haAccounts) {
|
||||||
|
for (const cal of acc.calendars) {
|
||||||
|
if (cal.id === calId) { cal.enabled = true; cal.sidebar_hidden = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await api.put(`/caldav/calendars/${calId}`, { enabled: true, sidebar_hidden: false });
|
await api.put(`/caldav/calendars/${calId}`, { enabled: true, sidebar_hidden: false });
|
||||||
for (const acc of state.accounts) {
|
for (const acc of state.accounts) {
|
||||||
|
|||||||
Reference in New Issue
Block a user