Compare commits
73 Commits
254adfa12a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58faf3876c | ||
|
|
639d7f3c9c | ||
|
|
a60c27f66f | ||
|
|
e744b1829e | ||
|
|
f09b5e7c48 | ||
|
|
8a34618453 | ||
|
|
64d499647d | ||
|
|
b120d9d430 | ||
|
|
371678aac4 | ||
|
|
6503d18637 | ||
|
|
ebe250ca01 | ||
|
|
f4bcdf458b | ||
|
|
e0a61b7368 | ||
|
|
7cabfb10de | ||
|
|
b9691ea209 | ||
|
|
49b1935a28 | ||
|
|
3d7779ae83 | ||
|
|
6c7c8a4662 | ||
|
|
0aeb421970 | ||
|
|
29fef6ea77 | ||
|
|
dd18a0b594 | ||
|
|
4aaf6672f7 | ||
|
|
ac5996693f | ||
|
|
0e6672b909 | ||
|
|
8d95dd0b97 | ||
|
|
e99f91dcf3 | ||
|
|
64f8b901dd | ||
|
|
c61d7fd698 | ||
|
|
b803d4bf4c | ||
|
|
d942b82e1d | ||
|
|
a700bc5350 | ||
|
|
9ae247c7c5 | ||
|
|
4b6839f1ff | ||
|
|
aee9689d46 | ||
|
|
3351263c85 | ||
|
|
20e98e660a | ||
|
|
134b238dea | ||
|
|
d4ea097831 | ||
|
|
e3984eb5cf | ||
|
|
58c7cbc38c | ||
|
|
3d4fdb3f8f | ||
|
|
c03af1b7ea | ||
|
|
c5c6a5f71b | ||
|
|
69f5789e2d | ||
|
|
978ad55af4 | ||
|
|
d6e67a97c8 | ||
|
|
5c7a74e221 | ||
|
|
f28aa706e7 | ||
|
|
5a7d8ad362 | ||
|
|
d1d1135e32 | ||
|
|
4c8face22a | ||
|
|
7070e23cc6 | ||
|
|
240b7af1c8 | ||
| 804d6ac9eb | |||
| 377a24eac6 | |||
| 6a25607103 | |||
| f50f5fa1e1 | |||
| 8fc3472b1c | |||
| fce162693c | |||
| e317b799d0 | |||
| 77936b3b8d | |||
| cae39e6086 | |||
| b40e8c6731 | |||
| ea7442db32 | |||
| bda4a75a11 | |||
| ba73bde353 | |||
|
|
0b4060beae | ||
|
|
d8ec22d573 | ||
|
|
faada7359e | ||
|
|
e9bc56e857 | ||
|
|
b268e88d84 | ||
|
|
7f92e0423c | ||
|
|
cd4879d573 |
@@ -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, vDatetime, vRecur
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -105,6 +105,10 @@ 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
|
||||||
|
recurrence_id = component.get("RECURRENCE-ID")
|
||||||
|
is_recurring = rrule_str is not None or recurrence_id is not None
|
||||||
|
|
||||||
dtstart_prop = component.get("DTSTART")
|
dtstart_prop = component.get("DTSTART")
|
||||||
dtend_prop = component.get("DTEND")
|
dtend_prop = component.get("DTEND")
|
||||||
@@ -154,6 +158,8 @@ 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,
|
||||||
|
"recurring": is_recurring,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -201,6 +207,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"))
|
||||||
@@ -208,10 +216,16 @@ def create_event(
|
|||||||
|
|
||||||
|
|
||||||
def update_event(
|
def update_event(
|
||||||
url: str, username: str, password: str, event_url: str, data: Dict
|
url: str, username: str, password: str, event_url: str, data: Dict,
|
||||||
|
calendar_url: str = None,
|
||||||
):
|
):
|
||||||
client = _client(url, username, password)
|
client = _client(url, username, password)
|
||||||
resource = client.event(url=event_url)
|
if calendar_url:
|
||||||
|
cal_obj = client.calendar(url=calendar_url)
|
||||||
|
resource = caldav.Event(client=client, url=event_url, parent=cal_obj)
|
||||||
|
else:
|
||||||
|
resource = caldav.Event(client=client, url=event_url)
|
||||||
|
resource.load()
|
||||||
raw = resource.data
|
raw = resource.data
|
||||||
|
|
||||||
cal = Calendar.from_ical(raw)
|
cal = Calendar.from_ical(raw)
|
||||||
@@ -226,26 +240,54 @@ def update_event(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if "title" in data or "summary" in data:
|
if "title" in data or "summary" in data:
|
||||||
component["SUMMARY"] = data.get("title", data.get("summary", ""))
|
if "SUMMARY" in component:
|
||||||
|
del component["SUMMARY"]
|
||||||
|
component.add("summary", data.get("title", data.get("summary", "")))
|
||||||
|
|
||||||
if "start" in data:
|
if "start" in data:
|
||||||
|
if "DTSTART" in component:
|
||||||
|
del component["DTSTART"]
|
||||||
if data.get("allDay"):
|
if data.get("allDay"):
|
||||||
component["DTSTART"] = date.fromisoformat(data["start"][:10])
|
component.add("dtstart", date.fromisoformat(data["start"][:10]))
|
||||||
else:
|
else:
|
||||||
component["DTSTART"] = _parse_dt(data["start"])
|
component.add("dtstart", _parse_dt(data["start"]))
|
||||||
|
|
||||||
if "end" in data:
|
if "end" in data:
|
||||||
|
if "DTEND" in component:
|
||||||
|
del component["DTEND"]
|
||||||
if data.get("allDay"):
|
if data.get("allDay"):
|
||||||
component["DTEND"] = date.fromisoformat(data["end"][:10])
|
component.add("dtend", date.fromisoformat(data["end"][:10]))
|
||||||
else:
|
else:
|
||||||
component["DTEND"] = _parse_dt(data["end"])
|
component.add("dtend", _parse_dt(data["end"]))
|
||||||
|
|
||||||
if "location" in data:
|
if "location" in data:
|
||||||
component["LOCATION"] = data["location"]
|
if "LOCATION" in component:
|
||||||
|
del component["LOCATION"]
|
||||||
|
component.add("location", data["location"])
|
||||||
if "description" in data:
|
if "description" in data:
|
||||||
component["DESCRIPTION"] = data["description"]
|
if "DESCRIPTION" in component:
|
||||||
|
del component["DESCRIPTION"]
|
||||||
|
component.add("description", data["description"])
|
||||||
if "color" in data:
|
if "color" in data:
|
||||||
component["X-CALENDARR-COLOR"] = data["color"]
|
if "X-CALENDARR-COLOR" in component:
|
||||||
|
del component["X-CALENDARR-COLOR"]
|
||||||
|
component.add("x-calendarr-color", data["color"])
|
||||||
|
if "rrule" in data:
|
||||||
|
if "RRULE" in component:
|
||||||
|
del component["RRULE"]
|
||||||
|
if data["rrule"]:
|
||||||
|
component.add("rrule", _parse_rrule_str(data["rrule"]))
|
||||||
|
|
||||||
|
if "exdate" in data and data["exdate"]:
|
||||||
|
# Parse YYYYMMDD string into a proper EXDATE
|
||||||
|
exdate_str = data["exdate"]
|
||||||
|
# Determine if event uses dates or datetimes
|
||||||
|
dtstart_prop = component.get("DTSTART")
|
||||||
|
if dtstart_prop and isinstance(dtstart_prop.dt, date) and not isinstance(dtstart_prop.dt, datetime):
|
||||||
|
exdate_val = date(int(exdate_str[:4]), int(exdate_str[4:6]), int(exdate_str[6:8]))
|
||||||
|
else:
|
||||||
|
exdate_val = datetime(int(exdate_str[:4]), int(exdate_str[4:6]), int(exdate_str[6:8]), tzinfo=timezone.utc)
|
||||||
|
component.add("exdate", [exdate_val])
|
||||||
|
|
||||||
new_cal.add_component(component)
|
new_cal.add_component(component)
|
||||||
|
|
||||||
@@ -253,12 +295,31 @@ def update_event(
|
|||||||
resource.save()
|
resource.save()
|
||||||
|
|
||||||
|
|
||||||
def delete_event(url: str, username: str, password: str, event_url: str):
|
def delete_event(url: str, username: str, password: str, event_url: str,
|
||||||
|
calendar_url: str = None):
|
||||||
client = _client(url, username, password)
|
client = _client(url, username, password)
|
||||||
resource = client.event(url=event_url)
|
if calendar_url:
|
||||||
|
cal_obj = client.calendar(url=calendar_url)
|
||||||
|
resource = caldav.Event(client=client, url=event_url, parent=cal_obj)
|
||||||
|
else:
|
||||||
|
resource = caldav.Event(client=client, url=event_url)
|
||||||
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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
@@ -63,6 +63,51 @@ def _migrate():
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute(text("ALTER TABLE homeassistant_accounts ADD COLUMN auth_method VARCHAR(20) DEFAULT 'token'"))
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute(text("ALTER TABLE homeassistant_accounts ADD COLUMN refresh_token TEXT"))
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute(text("ALTER TABLE homeassistant_accounts ADD COLUMN token_expiry DATETIME"))
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute(text("ALTER TABLE homeassistant_accounts ADD COLUMN client_id VARCHAR(500)"))
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute(text("ALTER TABLE local_events ADD COLUMN rrule TEXT"))
|
||||||
|
conn.commit()
|
||||||
|
logging.info("Migration: added rrule to local_events")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn.execute(text("ALTER TABLE local_events ADD COLUMN exdate TEXT"))
|
||||||
|
conn.commit()
|
||||||
|
logging.info("Migration: added exdate to local_events")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn.execute(text("ALTER TABLE user_settings ADD COLUMN month_divider_color VARCHAR(7) DEFAULT '#7090c0'"))
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn.execute(text("ALTER TABLE user_settings ADD COLUMN month_label_color VARCHAR(7) DEFAULT '#7090c0'"))
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
_migrate()
|
_migrate()
|
||||||
|
|
||||||
@@ -76,11 +121,35 @@ 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")
|
||||||
|
|
||||||
|
|
||||||
|
# ── PWA assets that must live at root scope ──────────────
|
||||||
|
@app.get("/manifest.json")
|
||||||
|
async def pwa_manifest():
|
||||||
|
return FileResponse(str(FRONTEND_DIR / "manifest.json"), media_type="application/manifest+json")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/sw.js")
|
||||||
|
async def pwa_service_worker():
|
||||||
|
return FileResponse(
|
||||||
|
str(FRONTEND_DIR / "sw.js"),
|
||||||
|
media_type="application/javascript",
|
||||||
|
headers={"Service-Worker-Allowed": "/", "Cache-Control": "no-cache"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/icons/{icon_name}")
|
||||||
|
async def pwa_icon(icon_name: str):
|
||||||
|
icon_path = FRONTEND_DIR / "icons" / icon_name
|
||||||
|
if not icon_path.exists() or not icon_path.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="Icon not found")
|
||||||
|
return FileResponse(str(icon_path))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/{full_path:path}")
|
@app.get("/{full_path:path}")
|
||||||
async def spa_fallback(full_path: str):
|
async def spa_fallback(full_path: str):
|
||||||
if full_path.startswith("api/"):
|
if full_path.startswith("api/"):
|
||||||
|
|||||||
@@ -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):
|
||||||
@@ -79,6 +82,8 @@ class UserSettings(Base):
|
|||||||
line_contrast = Column(Integer, default=3)
|
line_contrast = Column(Integer, default=3)
|
||||||
hour_height = Column(Integer, default=60)
|
hour_height = Column(Integer, default=60)
|
||||||
language = Column(String(5), default="de")
|
language = Column(String(5), default="de")
|
||||||
|
month_divider_color = Column(String(7), default="#7090c0")
|
||||||
|
month_label_color = Column(String(7), default="#7090c0")
|
||||||
|
|
||||||
user = relationship("User", back_populates="settings")
|
user = relationship("User", back_populates="settings")
|
||||||
|
|
||||||
@@ -109,6 +114,8 @@ 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)
|
||||||
|
exdate = Column(Text, nullable=True) # Comma-separated YYYYMMDD dates to exclude
|
||||||
|
|
||||||
calendar = relationship("LocalCalendar", back_populates="events")
|
calendar = relationship("LocalCalendar", back_populates="events")
|
||||||
|
|
||||||
@@ -176,3 +183,36 @@ 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)
|
||||||
|
auth_method = Column(String(20), default="token")
|
||||||
|
refresh_token = Column(Text, nullable=True)
|
||||||
|
token_expiry = Column(DateTime, nullable=True)
|
||||||
|
client_id = Column(String(500), nullable=True)
|
||||||
|
|
||||||
|
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")
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from datetime import timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import pyotp
|
import pyotp
|
||||||
@@ -11,6 +12,9 @@ import models
|
|||||||
from auth import create_access_token, get_current_user, get_password_hash, verify_password
|
from auth import create_access_token, get_current_user, get_password_hash, verify_password
|
||||||
from database import get_db
|
from database import get_db
|
||||||
|
|
||||||
|
# When "Angemeldet bleiben" is ticked the token lives for half a year.
|
||||||
|
REMEMBER_ME_EXPIRY = timedelta(days=180)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@@ -24,6 +28,7 @@ class LoginRequest(BaseModel):
|
|||||||
username: str
|
username: str
|
||||||
password: str
|
password: str
|
||||||
totp_code: Optional[str] = None
|
totp_code: Optional[str] = None
|
||||||
|
remember_me: Optional[bool] = False
|
||||||
|
|
||||||
|
|
||||||
def _user_dict(user: models.User) -> dict:
|
def _user_dict(user: models.User) -> dict:
|
||||||
@@ -98,7 +103,8 @@ def login_json(req: LoginRequest, db: Session = Depends(get_db)):
|
|||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Ungültiger 2FA-Code",
|
detail="Ungültiger 2FA-Code",
|
||||||
)
|
)
|
||||||
token = create_access_token({"sub": user.username})
|
expires = REMEMBER_ME_EXPIRY if req.remember_me else None
|
||||||
|
token = create_access_token({"sub": user.username}, expires_delta=expires)
|
||||||
return {"access_token": token, "token_type": "bearer", "user": _user_dict(user)}
|
return {"access_token": token, "token_type": "bearer", "user": _user_dict(user)}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,8 @@ 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
|
||||||
|
exdate: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
def _account_dict(a: models.CalDAVAccount) -> dict:
|
def _account_dict(a: models.CalDAVAccount) -> dict:
|
||||||
@@ -75,16 +82,137 @@ 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 = []
|
||||||
|
# Parse excluded dates
|
||||||
|
excluded = set()
|
||||||
|
if ev.exdate:
|
||||||
|
for d in ev.exdate.split(","):
|
||||||
|
d = d.strip()
|
||||||
|
if d:
|
||||||
|
excluded.add(d)
|
||||||
|
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_key = occ_start.strftime("%Y%m%d")
|
||||||
|
if occ_key in excluded:
|
||||||
|
continue
|
||||||
|
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_key = occ.strftime("%Y%m%d")
|
||||||
|
if occ_key in excluded:
|
||||||
|
continue
|
||||||
|
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
|
||||||
|
|
||||||
@@ -266,7 +394,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(
|
||||||
@@ -282,6 +410,7 @@ def get_events(
|
|||||||
ev["calendar_id"] = calendar.id
|
ev["calendar_id"] = calendar.id
|
||||||
ev["calendar_name"] = calendar.name
|
ev["calendar_name"] = calendar.name
|
||||||
ev["calendarColor"] = cal_color
|
ev["calendarColor"] = cal_color
|
||||||
|
ev["source"] = "caldav"
|
||||||
all_events.append(ev)
|
all_events.append(ev)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -302,12 +431,19 @@ 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:
|
||||||
|
if ev.rrule:
|
||||||
|
all_events.extend(_expand_recurring_local(ev, local_cal, start_dt, end_dt))
|
||||||
|
else:
|
||||||
all_events.append({
|
all_events.append({
|
||||||
"id": ev.uid,
|
"id": ev.uid,
|
||||||
"url": f"local://{ev.uid}",
|
"url": f"local://{ev.uid}",
|
||||||
@@ -318,6 +454,7 @@ def get_events(
|
|||||||
"location": ev.location or "",
|
"location": ev.location or "",
|
||||||
"description": ev.description or "",
|
"description": ev.description or "",
|
||||||
"color": ev.color,
|
"color": ev.color,
|
||||||
|
"rrule": None,
|
||||||
"calendar_id": f"local-{local_cal.id}",
|
"calendar_id": f"local-{local_cal.id}",
|
||||||
"calendar_name": local_cal.name,
|
"calendar_name": local_cal.name,
|
||||||
"calendarColor": local_cal.color,
|
"calendarColor": local_cal.color,
|
||||||
@@ -347,13 +484,28 @@ def get_events(
|
|||||||
.filter(models.GoogleAccount.user_id == current_user.id)
|
.filter(models.GoogleAccount.user_id == current_user.id)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
google_errors = []
|
||||||
for g_acc in google_accounts:
|
for g_acc in google_accounts:
|
||||||
try:
|
try:
|
||||||
all_events.extend(get_google_events(g_acc, start_dt, end_dt, db))
|
all_events.extend(get_google_events(g_acc, start_dt, end_dt, db))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
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})
|
||||||
|
|
||||||
return all_events
|
# ── 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, db))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Error fetching HA events for %s: %s", ha_acc.name, exc)
|
||||||
|
|
||||||
|
return {"events": all_events, "errors": google_errors}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/events")
|
@router.post("/events")
|
||||||
@@ -388,6 +540,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}
|
||||||
@@ -399,6 +552,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),
|
||||||
@@ -408,7 +562,26 @@ def update_event(
|
|||||||
.filter(models.CalDAVAccount.user_id == current_user.id)
|
.filter(models.CalDAVAccount.user_id == current_user.id)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
account = None
|
||||||
|
cal_url = 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)
|
||||||
|
cal_url = cal.cal_id
|
||||||
|
if not account:
|
||||||
account = _find_account_for_event_url(event_url, accounts)
|
account = _find_account_for_event_url(event_url, accounts)
|
||||||
|
# Try to find the calendar URL for the account
|
||||||
|
if account and not cal_url:
|
||||||
|
for c in account.calendars:
|
||||||
|
if event_url.startswith(c.cal_id) or event_url.startswith(_normalize_url(c.cal_id)):
|
||||||
|
cal_url = c.cal_id
|
||||||
|
break
|
||||||
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:
|
||||||
@@ -418,6 +591,7 @@ def update_event(
|
|||||||
account.password,
|
account.password,
|
||||||
event_url,
|
event_url,
|
||||||
data.model_dump(exclude_none=True) if data else {},
|
data.model_dump(exclude_none=True) if data else {},
|
||||||
|
calendar_url=cal_url,
|
||||||
)
|
)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -428,6 +602,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),
|
||||||
):
|
):
|
||||||
@@ -436,12 +611,31 @@ def delete_event(
|
|||||||
.filter(models.CalDAVAccount.user_id == current_user.id)
|
.filter(models.CalDAVAccount.user_id == current_user.id)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
account = None
|
||||||
|
cal_url = 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)
|
||||||
|
cal_url = cal.cal_id
|
||||||
|
if not account:
|
||||||
account = _find_account_for_event_url(event_url, accounts)
|
account = _find_account_for_event_url(event_url, accounts)
|
||||||
|
if account and not cal_url:
|
||||||
|
for c in account.calendars:
|
||||||
|
if event_url.startswith(c.cal_id) or event_url.startswith(_normalize_url(c.cal_id)):
|
||||||
|
cal_url = c.cal_id
|
||||||
|
break
|
||||||
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:
|
||||||
caldav_client.delete_event(
|
caldav_client.delete_event(
|
||||||
account.url, account.username, account.password, event_url
|
account.url, account.username, account.password, event_url,
|
||||||
|
calendar_url=cal_url,
|
||||||
)
|
)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ SKIP_GOOGLE_CALENDAR_IDS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_system_calendar(cal_id: str) -> bool:
|
||||||
|
"""Return True for virtual/system calendars that should be hidden."""
|
||||||
|
return cal_id in SKIP_GOOGLE_CALENDAR_IDS or "weeknum" in cal_id.lower()
|
||||||
|
|
||||||
|
|
||||||
def _google_configured() -> bool:
|
def _google_configured() -> bool:
|
||||||
return bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET)
|
return bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET)
|
||||||
|
|
||||||
@@ -139,7 +144,7 @@ def _account_dict(a: models.GoogleAccount) -> dict:
|
|||||||
"sidebar_hidden": bool(c.sidebar_hidden),
|
"sidebar_hidden": bool(c.sidebar_hidden),
|
||||||
}
|
}
|
||||||
for c in a.calendars
|
for c in a.calendars
|
||||||
if c.cal_id not in SKIP_GOOGLE_CALENDAR_IDS
|
if not _is_system_calendar(c.cal_id)
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,12 +154,16 @@ def _sync_google_calendars(account: models.GoogleAccount, db: Session):
|
|||||||
try:
|
try:
|
||||||
token = _refresh_access_token(account, db)
|
token = _refresh_access_token(account, db)
|
||||||
cal_list = _google_api(token, "/users/me/calendarList")
|
cal_list = _google_api(token, "/users/me/calendarList")
|
||||||
existing = {c.cal_id: c for c in account.calendars}
|
# Remove any previously stored system calendars (e.g. locale-specific weeknum variants)
|
||||||
|
for c in list(account.calendars):
|
||||||
|
if _is_system_calendar(c.cal_id):
|
||||||
|
db.delete(c)
|
||||||
|
existing = {c.cal_id: c for c in account.calendars if not _is_system_calendar(c.cal_id)}
|
||||||
for cal in cal_list.get("items", []):
|
for cal in cal_list.get("items", []):
|
||||||
if cal.get("deleted"):
|
if cal.get("deleted"):
|
||||||
continue
|
continue
|
||||||
cal_id = cal["id"]
|
cal_id = cal["id"]
|
||||||
if cal_id in SKIP_GOOGLE_CALENDAR_IDS:
|
if _is_system_calendar(cal_id):
|
||||||
continue
|
continue
|
||||||
if cal_id not in existing:
|
if cal_id not in existing:
|
||||||
db.add(models.GoogleCalendar(
|
db.add(models.GoogleCalendar(
|
||||||
@@ -373,16 +382,17 @@ def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt:
|
|||||||
"""Fetch events from all enabled Google calendars for an account."""
|
"""Fetch events from all enabled Google calendars for an account."""
|
||||||
try:
|
try:
|
||||||
token = _refresh_access_token(account, db)
|
token = _refresh_access_token(account, db)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
return []
|
logger.error("Token refresh failed for Google account %s: %s", account.email, exc)
|
||||||
|
raise
|
||||||
|
|
||||||
all_events = []
|
all_events = []
|
||||||
try:
|
|
||||||
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 gcal.cal_id in SKIP_GOOGLE_CALENDAR_IDS:
|
if _is_system_calendar(gcal.cal_id):
|
||||||
continue
|
continue
|
||||||
|
try:
|
||||||
events_resp = _google_api(token, f"/calendars/{gcal.cal_id}/events", params={
|
events_resp = _google_api(token, f"/calendars/{gcal.cal_id}/events", params={
|
||||||
"timeMin": start_dt.isoformat(),
|
"timeMin": start_dt.isoformat(),
|
||||||
"timeMax": end_dt.isoformat(),
|
"timeMax": end_dt.isoformat(),
|
||||||
@@ -394,7 +404,7 @@ def get_google_events(account: models.GoogleAccount, start_dt: datetime, end_dt:
|
|||||||
continue
|
continue
|
||||||
all_events.append(_parse_google_event(ev, gcal.id, gcal.name, gcal.color or "#4285f4"))
|
all_events.append(_parse_google_event(ev, gcal.id, gcal.name, gcal.color or "#4285f4"))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Error fetching Google Calendar for %s: %s", account.email, exc)
|
logger.error("Error fetching events for calendar %s (%s): %s", gcal.name, gcal.cal_id, exc)
|
||||||
|
|
||||||
return all_events
|
return all_events
|
||||||
|
|
||||||
|
|||||||
735
backend/routers/homeassistant_router.py
Normal file
735
backend/routers/homeassistant_router.py
Normal file
@@ -0,0 +1,735 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
import ssl
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import requests as http_requests
|
||||||
|
import websocket as ws_client
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
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"
|
||||||
|
|
||||||
|
# In-memory store for pending OAuth states (short-lived, ~10 min TTL)
|
||||||
|
_pending_oauth: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_pending():
|
||||||
|
now = time.time()
|
||||||
|
for k in [k for k, v in _pending_oauth.items() if v["expires"] < now]:
|
||||||
|
_pending_oauth.pop(k, None)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Auth helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def _ha_refresh(url: str, refresh_token: str, client_id: str) -> tuple:
|
||||||
|
"""Refresh grant → (access_token, expires_in)"""
|
||||||
|
resp = http_requests.post(
|
||||||
|
f"{url.rstrip('/')}/auth/token",
|
||||||
|
data={
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
"client_id": client_id,
|
||||||
|
},
|
||||||
|
timeout=10,
|
||||||
|
verify=False,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
return data["access_token"], data.get("expires_in", 1800)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_valid_token(account: models.HomeAssistantAccount, db: Session) -> str:
|
||||||
|
"""Return a valid access token, refreshing if necessary."""
|
||||||
|
if account.auth_method != "oauth":
|
||||||
|
return account.token # Long-Lived Token läuft nicht ab
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if account.token_expiry and account.token_expiry.replace(tzinfo=timezone.utc) > now:
|
||||||
|
return account.token
|
||||||
|
# Needs refresh
|
||||||
|
try:
|
||||||
|
access_token, expires_in = _ha_refresh(
|
||||||
|
account.url, account.refresh_token, account.client_id or ""
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("HA token refresh failed for %s: %s", account.name, exc)
|
||||||
|
raise HTTPException(401, "Home Assistant Token abgelaufen, bitte Konto neu verbinden")
|
||||||
|
account.token = access_token
|
||||||
|
account.token_expiry = datetime.fromtimestamp(now.timestamp() + expires_in, tz=timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
return access_token
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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(400, "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 _ha_format_dt(s: str) -> str:
|
||||||
|
"""Convert ISO datetime to HA format with timezone, no milliseconds.
|
||||||
|
|
||||||
|
HA's cv.datetime accepts ISO 8601. Keep the timezone offset so HA
|
||||||
|
interprets the time correctly regardless of HA's local timezone.
|
||||||
|
"""
|
||||||
|
# Frontend sends "2026-05-07T15:00:00.000Z"; normalize Z to +00:00
|
||||||
|
if s.endswith("Z"):
|
||||||
|
s = s[:-1] + "+00:00"
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(s)
|
||||||
|
except ValueError:
|
||||||
|
# Strip fractional seconds if fromisoformat can't handle them
|
||||||
|
import re
|
||||||
|
s2 = re.sub(r"\.\d+", "", s)
|
||||||
|
dt = datetime.fromisoformat(s2)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def _ha_build_event_body(entity_id: str, data: dict) -> dict:
|
||||||
|
"""Build a service-call body for create_event / update_event."""
|
||||||
|
body = {"entity_id": entity_id}
|
||||||
|
if data.get("title"):
|
||||||
|
body["summary"] = data["title"]
|
||||||
|
if data.get("description"):
|
||||||
|
body["description"] = data["description"]
|
||||||
|
if data.get("location"):
|
||||||
|
body["location"] = data["location"]
|
||||||
|
if data.get("start") and data.get("end"):
|
||||||
|
if data.get("allDay"):
|
||||||
|
body["start_date"] = data["start"][:10]
|
||||||
|
body["end_date"] = data["end"][:10]
|
||||||
|
else:
|
||||||
|
body["start_date_time"] = _ha_format_dt(data["start"])
|
||||||
|
body["end_date_time"] = _ha_format_dt(data["end"])
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _ha_ws_call(url: str, token: str, command: dict) -> dict:
|
||||||
|
"""Send a single WebSocket command to HA and return the result.
|
||||||
|
|
||||||
|
Used for calendar/event/delete and calendar/event/update which are
|
||||||
|
only exposed via WebSocket, not as service calls.
|
||||||
|
"""
|
||||||
|
# Convert http(s):// → ws(s)://
|
||||||
|
if url.startswith("https://"):
|
||||||
|
ws_url = "wss://" + url[len("https://"):].rstrip("/") + "/api/websocket"
|
||||||
|
sslopt = {"cert_reqs": ssl.CERT_NONE}
|
||||||
|
elif url.startswith("http://"):
|
||||||
|
ws_url = "ws://" + url[len("http://"):].rstrip("/") + "/api/websocket"
|
||||||
|
sslopt = None
|
||||||
|
else:
|
||||||
|
raise Exception(f"Ungültige HA-URL: {url}")
|
||||||
|
|
||||||
|
sock = ws_client.create_connection(ws_url, timeout=15, sslopt=sslopt)
|
||||||
|
try:
|
||||||
|
# 1. auth_required
|
||||||
|
msg = json.loads(sock.recv())
|
||||||
|
if msg.get("type") != "auth_required":
|
||||||
|
raise Exception(f"Unerwartete WS-Antwort: {msg}")
|
||||||
|
# 2. send auth
|
||||||
|
sock.send(json.dumps({"type": "auth", "access_token": token}))
|
||||||
|
msg = json.loads(sock.recv())
|
||||||
|
if msg.get("type") != "auth_ok":
|
||||||
|
raise Exception(f"WS Auth fehlgeschlagen: {msg.get('message', msg)}")
|
||||||
|
# 3. send command
|
||||||
|
cmd = {"id": 1, **command}
|
||||||
|
logger.info("HA WS command: %s", cmd)
|
||||||
|
sock.send(json.dumps(cmd))
|
||||||
|
# 4. receive result (might receive other messages first, ignore them)
|
||||||
|
for _ in range(10):
|
||||||
|
msg = json.loads(sock.recv())
|
||||||
|
if msg.get("id") == 1:
|
||||||
|
if msg.get("success"):
|
||||||
|
return msg.get("result", {})
|
||||||
|
err = msg.get("error", {})
|
||||||
|
raise Exception(f"{err.get('code', 'error')}: {err.get('message', 'Unbekannter Fehler')}")
|
||||||
|
raise Exception("Keine Antwort von HA WebSocket")
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
sock.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _ha_ws_event_payload(data: dict) -> dict:
|
||||||
|
"""Build the 'event' payload for calendar/event/create|update WS commands."""
|
||||||
|
payload = {}
|
||||||
|
if data.get("title"):
|
||||||
|
payload["summary"] = data["title"]
|
||||||
|
if data.get("description"):
|
||||||
|
payload["description"] = data["description"]
|
||||||
|
if data.get("location"):
|
||||||
|
payload["location"] = data["location"]
|
||||||
|
if data.get("start") and data.get("end"):
|
||||||
|
if data.get("allDay"):
|
||||||
|
payload["dtstart"] = data["start"][:10]
|
||||||
|
payload["dtend"] = data["end"][:10]
|
||||||
|
else:
|
||||||
|
payload["dtstart"] = _ha_format_dt(data["start"])
|
||||||
|
payload["dtend"] = _ha_format_dt(data["end"])
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _ha_create_event(url: str, token: str, entity_id: str, data: dict) -> dict:
|
||||||
|
"""Create a new event. Tries WebSocket command first, falls back to service call."""
|
||||||
|
# Try WebSocket calendar/event/create
|
||||||
|
try:
|
||||||
|
return _ha_ws_call(url, token, {
|
||||||
|
"type": "calendar/event/create",
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"event": _ha_ws_event_payload(data),
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.info("HA WS create failed (%s), falling back to service call", exc)
|
||||||
|
|
||||||
|
# Fallback: service call
|
||||||
|
base = url.rstrip("/")
|
||||||
|
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||||
|
body = _ha_build_event_body(entity_id, data)
|
||||||
|
logger.info("HA create_event body: %s", body)
|
||||||
|
resp = http_requests.post(
|
||||||
|
f"{base}/api/services/calendar/create_event",
|
||||||
|
headers=headers, json=body, timeout=15, verify=False,
|
||||||
|
)
|
||||||
|
if not resp.ok:
|
||||||
|
try:
|
||||||
|
detail = resp.json().get("message", resp.text[:500])
|
||||||
|
except Exception:
|
||||||
|
detail = resp.text[:500] if resp.text else f"HTTP {resp.status_code}"
|
||||||
|
raise Exception(f"HA create_event ({resp.status_code}): {detail}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _ha_update_event(url: str, token: str, entity_id: str, uid: str, data: dict):
|
||||||
|
"""Update an event. Tries calendar/event/update first; if the integration
|
||||||
|
doesn't support update (e.g. Google Calendar via HA), falls back to
|
||||||
|
delete+create so the user still sees their changes."""
|
||||||
|
try:
|
||||||
|
return _ha_ws_call(url, token, {
|
||||||
|
"type": "calendar/event/update",
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"uid": uid,
|
||||||
|
"event": _ha_ws_event_payload(data),
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
msg = str(exc).lower()
|
||||||
|
if "not_supported" not in msg and "does not support" not in msg:
|
||||||
|
raise
|
||||||
|
logger.info("HA update not supported for %s, falling back to delete+create", entity_id)
|
||||||
|
|
||||||
|
# Fallback: delete the old event, then create a new one
|
||||||
|
try:
|
||||||
|
_ha_ws_call(url, token, {
|
||||||
|
"type": "calendar/event/delete",
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"uid": uid,
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("HA delete during update fallback failed: %s", exc)
|
||||||
|
# Continue anyway — try the create
|
||||||
|
|
||||||
|
return _ha_ws_call(url, token, {
|
||||||
|
"type": "calendar/event/create",
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"event": _ha_ws_event_payload(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _ha_delete_event(url: str, token: str, entity_id: str, uid: str):
|
||||||
|
"""Delete an event via WebSocket command (the only reliable path)."""
|
||||||
|
return _ha_ws_call(url, token, {
|
||||||
|
"type": "calendar/event/delete",
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"uid": uid,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
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, db: Session) -> list:
|
||||||
|
all_events = []
|
||||||
|
try:
|
||||||
|
token = _get_valid_token(account, db)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("HA token error for %s: %s", account.name, exc)
|
||||||
|
raise
|
||||||
|
for cal in account.calendars:
|
||||||
|
if not cal.enabled or cal.sidebar_hidden:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
raw = _ha_get_events(account.url, 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,
|
||||||
|
"auth_method": a.auth_method or "token",
|
||||||
|
"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 HAOAuthStart(BaseModel):
|
||||||
|
name: str
|
||||||
|
url: str
|
||||||
|
client_id: str
|
||||||
|
redirect_uri: 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),
|
||||||
|
):
|
||||||
|
"""Create a HA account from a Long-Lived Access Token."""
|
||||||
|
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,
|
||||||
|
auth_method="token",
|
||||||
|
refresh_token=None,
|
||||||
|
token_expiry=None,
|
||||||
|
)
|
||||||
|
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.post("/auth-url")
|
||||||
|
def oauth_start(
|
||||||
|
data: HAOAuthStart,
|
||||||
|
current_user: models.User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Start the OAuth flow: store pending state, return HA authorization URL."""
|
||||||
|
_cleanup_pending()
|
||||||
|
state_token = secrets.token_urlsafe(32)
|
||||||
|
_pending_oauth[state_token] = {
|
||||||
|
"user_id": current_user.id,
|
||||||
|
"ha_url": data.url.rstrip('/'),
|
||||||
|
"name": data.name,
|
||||||
|
"client_id": data.client_id,
|
||||||
|
"redirect_uri": data.redirect_uri,
|
||||||
|
"expires": time.time() + 600,
|
||||||
|
}
|
||||||
|
params = {
|
||||||
|
"client_id": data.client_id,
|
||||||
|
"redirect_uri": data.redirect_uri,
|
||||||
|
"state": state_token,
|
||||||
|
"response_type": "code",
|
||||||
|
}
|
||||||
|
return {"url": f"{data.url.rstrip('/')}/auth/authorize?{urlencode(params)}"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/callback")
|
||||||
|
def oauth_callback(
|
||||||
|
request: Request,
|
||||||
|
code: str = Query(""),
|
||||||
|
state: str = Query(""),
|
||||||
|
error: str = Query(""),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Callback from Home Assistant after user authorization."""
|
||||||
|
if error or not code:
|
||||||
|
return RedirectResponse(url=f"/?ha_error={error or 'no_code'}", status_code=302)
|
||||||
|
|
||||||
|
pending = _pending_oauth.pop(state, None)
|
||||||
|
if not pending or pending["expires"] < time.time():
|
||||||
|
return RedirectResponse(url="/?ha_error=state_expired", status_code=302)
|
||||||
|
|
||||||
|
ha_url = pending["ha_url"]
|
||||||
|
client_id = pending["client_id"]
|
||||||
|
|
||||||
|
# Exchange code for tokens
|
||||||
|
try:
|
||||||
|
resp = http_requests.post(
|
||||||
|
f"{ha_url}/auth/token",
|
||||||
|
data={
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"client_id": client_id,
|
||||||
|
},
|
||||||
|
timeout=15,
|
||||||
|
verify=False,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("HA token exchange connection error: %s", exc)
|
||||||
|
return RedirectResponse(url="/?ha_error=ha_unreachable", status_code=302)
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.error("HA token exchange failed (%s): %s", resp.status_code, resp.text)
|
||||||
|
return RedirectResponse(url="/?ha_error=token_exchange_failed", status_code=302)
|
||||||
|
|
||||||
|
tokens = resp.json()
|
||||||
|
access_token = tokens["access_token"]
|
||||||
|
refresh_token = tokens.get("refresh_token", "")
|
||||||
|
expires_in = tokens.get("expires_in", 1800)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
remote_cals = _ha_get_calendars(ha_url, access_token)
|
||||||
|
except HTTPException as exc:
|
||||||
|
logger.error("HA calendar fetch failed after OAuth: %s", exc.detail)
|
||||||
|
return RedirectResponse(url="/?ha_error=calendars_failed", status_code=302)
|
||||||
|
|
||||||
|
account = models.HomeAssistantAccount(
|
||||||
|
user_id=pending["user_id"],
|
||||||
|
name=pending["name"],
|
||||||
|
url=ha_url,
|
||||||
|
token=access_token,
|
||||||
|
auth_method="oauth",
|
||||||
|
refresh_token=refresh_token,
|
||||||
|
token_expiry=datetime.fromtimestamp(now.timestamp() + expires_in, tz=timezone.utc),
|
||||||
|
client_id=client_id,
|
||||||
|
)
|
||||||
|
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()
|
||||||
|
return RedirectResponse(url="/?ha_connected=1", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
@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")
|
||||||
|
|
||||||
|
token = _get_valid_token(acc, db)
|
||||||
|
remote_cals = _ha_get_calendars(acc.url, 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}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Event CRUD ───────────────────────────────────────────
|
||||||
|
|
||||||
|
class HAEventUpdate(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
|
||||||
|
|
||||||
|
|
||||||
|
class HAEventCreate(BaseModel):
|
||||||
|
calendar_id: int
|
||||||
|
title: str
|
||||||
|
start: str
|
||||||
|
end: str
|
||||||
|
allDay: bool = False
|
||||||
|
location: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/events")
|
||||||
|
def create_event(
|
||||||
|
data: HAEventCreate,
|
||||||
|
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 == data.calendar_id,
|
||||||
|
models.HomeAssistantAccount.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not cal:
|
||||||
|
raise HTTPException(404, "Calendar not found")
|
||||||
|
account = cal.account
|
||||||
|
token = _get_valid_token(account, db)
|
||||||
|
try:
|
||||||
|
_ha_create_event(
|
||||||
|
account.url, token, cal.entity_id,
|
||||||
|
data.model_dump(exclude_none=True),
|
||||||
|
)
|
||||||
|
return {"ok": True}
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(500, f"HA event create failed: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/events/{calendar_id}/{uid}")
|
||||||
|
def update_event(
|
||||||
|
calendar_id: int,
|
||||||
|
uid: str,
|
||||||
|
data: HAEventUpdate,
|
||||||
|
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")
|
||||||
|
account = cal.account
|
||||||
|
token = _get_valid_token(account, db)
|
||||||
|
try:
|
||||||
|
_ha_update_event(
|
||||||
|
account.url, token, cal.entity_id, uid,
|
||||||
|
data.model_dump(exclude_none=True),
|
||||||
|
)
|
||||||
|
return {"ok": True}
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(500, f"HA event update failed: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/events/{calendar_id}/{uid}")
|
||||||
|
def delete_event(
|
||||||
|
calendar_id: int,
|
||||||
|
uid: str,
|
||||||
|
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")
|
||||||
|
account = cal.account
|
||||||
|
token = _get_valid_token(account, db)
|
||||||
|
try:
|
||||||
|
_ha_delete_event(account.url, token, cal.entity_id, uid)
|
||||||
|
return {"ok": True}
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(500, f"HA event delete failed: {exc}")
|
||||||
@@ -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,8 @@ 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
|
||||||
|
exdate: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
def _cal_dict(cal: models.LocalCalendar) -> dict:
|
def _cal_dict(cal: models.LocalCalendar) -> dict:
|
||||||
@@ -64,6 +67,8 @@ 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,
|
||||||
|
"exdate": ev.exdate,
|
||||||
"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 +185,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 +225,14 @@ 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
|
||||||
|
if data.exdate is not None:
|
||||||
|
existing = ev.exdate or ""
|
||||||
|
dates = [d for d in existing.split(",") if d]
|
||||||
|
if data.exdate not in dates:
|
||||||
|
dates.append(data.exdate)
|
||||||
|
ev.exdate = ",".join(dates)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ class SettingsUpdate(BaseModel):
|
|||||||
line_contrast: Optional[int] = None
|
line_contrast: Optional[int] = None
|
||||||
hour_height: Optional[int] = None
|
hour_height: Optional[int] = None
|
||||||
language: Optional[str] = None
|
language: Optional[str] = None
|
||||||
|
month_divider_color: Optional[str] = None
|
||||||
|
month_label_color: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
def _settings_dict(s: models.UserSettings) -> dict:
|
def _settings_dict(s: models.UserSettings) -> dict:
|
||||||
@@ -36,6 +38,8 @@ def _settings_dict(s: models.UserSettings) -> dict:
|
|||||||
"line_contrast": s.line_contrast or 3,
|
"line_contrast": s.line_contrast or 3,
|
||||||
"hour_height": s.hour_height or 60,
|
"hour_height": s.hour_height or 60,
|
||||||
"language": s.language or "de",
|
"language": s.language or "de",
|
||||||
|
"month_divider_color": s.month_divider_color or "#7090c0",
|
||||||
|
"month_label_color": s.month_label_color or "#7090c0",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1002
frontend/css/app.css
1002
frontend/css/app.css
File diff suppressed because it is too large
Load Diff
BIN
frontend/icons/icon-192.png
Normal file
BIN
frontend/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/icons/icon-512.png
Normal file
BIN
frontend/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
9
frontend/icons/icon.svg
Normal file
9
frontend/icons/icon.svg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" fill="#4285f4"/>
|
||||||
|
<g fill="none" stroke="#ffffff" stroke-width="23" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="102" y="154" width="307" height="266" rx="26"/>
|
||||||
|
<line x1="102" y1="234" x2="409" y2="234"/>
|
||||||
|
<line x1="170" y1="103" x2="170" y2="180"/>
|
||||||
|
<line x1="342" y1="103" x2="342" y2="180"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 432 B |
@@ -2,9 +2,16 @@
|
|||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
<title>Calendarr</title>
|
<!-- APP_VERSION: update here + version.js on every release -->
|
||||||
|
<title>Calendarr v11</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<meta name="theme-color" content="#4285f4" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Calendarr" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.css" />
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.css" />
|
||||||
<link rel="stylesheet" href="/static/css/app.css" />
|
<link rel="stylesheet" href="/static/css/app.css" />
|
||||||
</head>
|
</head>
|
||||||
@@ -66,11 +73,14 @@
|
|||||||
<label>2FA-Code</label>
|
<label>2FA-Code</label>
|
||||||
<input type="text" id="login-totp" placeholder="6-stelliger Code" maxlength="6" inputmode="numeric" autocomplete="one-time-code" />
|
<input type="text" id="login-totp" placeholder="6-stelliger Code" maxlength="6" inputmode="numeric" autocomplete="one-time-code" />
|
||||||
</div>
|
</div>
|
||||||
|
<label class="toggle-label" style="margin-bottom:14px">
|
||||||
|
<input type="checkbox" id="login-remember" /> Angemeldet bleiben
|
||||||
|
</label>
|
||||||
<div id="login-error" class="form-error hidden"></div>
|
<div id="login-error" class="form-error hidden"></div>
|
||||||
<button type="submit" class="btn btn-primary btn-full">Anmelden</button>
|
<button type="submit" class="btn btn-primary btn-full">Anmelden</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<button class="impressum-link" onclick="openImpressum()">© 2026 Scarriffleservices</button>
|
<button class="impressum-link" onclick="openImpressum()">© 2026 Scarriffleservices · v11</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ─── MAIN APP ──────────────────────────────────────────── -->
|
<!-- ─── MAIN APP ──────────────────────────────────────────── -->
|
||||||
@@ -99,11 +109,25 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="topbar-right">
|
<div class="topbar-right">
|
||||||
<div class="view-switcher">
|
<div class="view-switcher">
|
||||||
|
<button class="view-btn" data-view="quarter" data-i18n="view_quarter">Quartal</button>
|
||||||
<button class="view-btn" data-view="month" data-i18n="view_month">Monat</button>
|
<button class="view-btn" data-view="month" data-i18n="view_month">Monat</button>
|
||||||
<button class="view-btn" data-view="week" data-i18n="view_week">Woche</button>
|
<button class="view-btn" data-view="week" data-i18n="view_week">Woche</button>
|
||||||
<button class="view-btn" data-view="day" data-i18n="view_day">Tag</button>
|
<button class="view-btn" data-view="day" data-i18n="view_day">Tag</button>
|
||||||
<button class="view-btn" data-view="agenda" data-i18n="view_agenda">Termine</button>
|
<button class="view-btn" data-view="agenda" data-i18n="view_agenda">Termine</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="view-mobile-wrapper">
|
||||||
|
<button class="icon-btn" id="btn-view-mobile" title="Ansicht wechseln" aria-label="Ansicht wechseln">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z"/></svg>
|
||||||
|
</button>
|
||||||
|
<div id="view-mobile-dropdown" class="user-dropdown hidden" style="right:0;top:50px;min-width:180px">
|
||||||
|
<button class="dropdown-item" data-mobile-view="quarter" data-i18n="view_quarter">Quartal</button>
|
||||||
|
<button class="dropdown-item" data-mobile-view="month" data-i18n="view_month">Monat</button>
|
||||||
|
<button class="dropdown-item" data-mobile-view="week" data-i18n="view_week">Woche</button>
|
||||||
|
<button class="dropdown-item" data-mobile-view="day" data-i18n="view_day">Tag</button>
|
||||||
|
<button class="dropdown-item" data-mobile-view="agenda" data-i18n="view_agenda">Termine</button>
|
||||||
|
<button class="dropdown-item" id="btn-today-mobile" style="border-top:1px solid var(--border)" data-i18n="btn_today">Heute</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button class="icon-btn" id="btn-settings" data-i18n-title="settings_title" title="Einstellungen">
|
<button class="icon-btn" id="btn-settings" data-i18n-title="settings_title" title="Einstellungen">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -115,8 +139,12 @@
|
|||||||
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
|
||||||
<span data-i18n="btn_profile">Profil</span>
|
<span data-i18n="btn_profile">Profil</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="dropdown-item dropdown-item-mobile-only" id="btn-settings-from-user">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/></svg>
|
||||||
|
<span data-i18n="settings_title">Einstellungen</span>
|
||||||
|
</button>
|
||||||
<button class="dropdown-item" id="btn-logout">
|
<button class="dropdown-item" id="btn-logout">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v24c0 1.1.9 2 2 2h8v-2H4V5z"/></svg>
|
||||||
<span data-i18n="btn_logout">Abmelden</span>
|
<span data-i18n="btn_logout">Abmelden</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,21 +185,23 @@
|
|||||||
<span data-i18n="my_calendars">Meine Kalender</span>
|
<span data-i18n="my_calendars">Meine Kalender</span>
|
||||||
<div class="add-cal-dropdown-wrap">
|
<div class="add-cal-dropdown-wrap">
|
||||||
<button class="icon-btn mini-btn" id="btn-add-cal" title="Kalender hinzufügen">
|
<button class="icon-btn mini-btn" id="btn-add-cal" title="Kalender hinzufügen">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v11h-2v-6H5v-2h6V5h2v11h6v2z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="add-cal-dropdown hidden" id="add-cal-dropdown">
|
<div class="add-cal-dropdown hidden" id="add-cal-dropdown">
|
||||||
<button data-action="local">Lokaler Kalender</button>
|
<button data-action="local">Lokaler Kalender</button>
|
||||||
<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>
|
||||||
<div id="cal-list-items"></div>
|
<div id="cal-list-items"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="sidebar-copyright" onclick="openImpressum()">© 2026 Scarriffleservices</button>
|
<button class="sidebar-copyright" onclick="openImpressum()">© 2026 Scarriffleservices · v11</button>
|
||||||
</aside>
|
</aside>
|
||||||
|
<div id="sidebar-backdrop" class="sidebar-backdrop"></div>
|
||||||
|
|
||||||
<!-- MAIN VIEW -->
|
<!-- MAIN VIEW -->
|
||||||
<main class="main-view" id="main-view">
|
<main class="main-view" id="main-view">
|
||||||
@@ -202,21 +232,87 @@
|
|||||||
<div class="form-row" id="ev-time-row">
|
<div class="form-row" id="ev-time-row">
|
||||||
<div class="form-group half">
|
<div class="form-group half">
|
||||||
<label>Start</label>
|
<label>Start</label>
|
||||||
<input type="datetime-local" id="ev-start" />
|
<input type="hidden" id="ev-start" />
|
||||||
|
<div class="dt-display" id="ev-start-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 10h5v11H7z"/></svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group half">
|
<div class="form-group half">
|
||||||
<label>Ende</label>
|
<label>Ende</label>
|
||||||
<input type="datetime-local" id="ev-end" />
|
<input type="hidden" id="ev-end" />
|
||||||
|
<div class="dt-display" id="ev-end-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 10h5v11H7z"/></svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row" id="ev-date-row" style="display:none">
|
<div class="form-row" id="ev-date-row" style="display:none">
|
||||||
<div class="form-group half">
|
<div class="form-group half">
|
||||||
<label>Start</label>
|
<label>Start</label>
|
||||||
<input type="date" id="ev-start-date" />
|
<input type="hidden" id="ev-start-date" />
|
||||||
|
<div class="dt-display" id="ev-start-date-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 10h5v11H7z"/></svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group half">
|
<div class="form-group half">
|
||||||
<label>Ende</label>
|
<label>Ende</label>
|
||||||
<input type="date" id="ev-end-date" />
|
<input type="hidden" id="ev-end-date" />
|
||||||
|
<div class="dt-display" id="ev-end-date-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 10h5v11H7z"/></svg>
|
||||||
|
</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 10h5v11H7z"/></svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -248,6 +344,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirm Modal -->
|
||||||
|
<div id="modal-delete-confirm" class="modal-overlay hidden">
|
||||||
|
<div class="modal-card" style="max-width:400px">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="delete-confirm-title">Termin löschen</h3>
|
||||||
|
<button class="icon-btn modal-close" data-modal="modal-delete-confirm">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p id="delete-confirm-text"></p>
|
||||||
|
<div id="delete-series-options" class="hidden" style="margin-top:12px">
|
||||||
|
<label class="toggle-label" style="display:block;margin-bottom:8px">
|
||||||
|
<input type="radio" name="delete-scope" value="single" checked /> Nur diesen Termin
|
||||||
|
</label>
|
||||||
|
<label class="toggle-label" style="display:block">
|
||||||
|
<input type="radio" name="delete-scope" value="all" /> Alle Serienelemente
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<button class="btn btn-ghost" data-modal="modal-delete-confirm">Abbrechen</button>
|
||||||
|
<button class="btn btn-danger" id="delete-confirm-ok">Löschen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Event Detail Popup -->
|
<!-- Event Detail Popup -->
|
||||||
<div id="popup-event" class="event-popup hidden">
|
<div id="popup-event" class="event-popup hidden">
|
||||||
<div class="popup-header">
|
<div class="popup-header">
|
||||||
@@ -256,8 +378,11 @@
|
|||||||
<button class="icon-btn popup-action" id="popup-edit" title="Bearbeiten">
|
<button class="icon-btn popup-action" id="popup-edit" title="Bearbeiten">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="icon-btn popup-action" id="popup-copy" title="Kopieren nach…">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v24h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v24c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v24z"/></svg>
|
||||||
|
</button>
|
||||||
<button class="icon-btn popup-action" id="popup-delete" title="Löschen">
|
<button class="icon-btn popup-action" id="popup-delete" title="Löschen">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v22zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="icon-btn popup-close" id="popup-close">×</button>
|
<button class="icon-btn popup-close" id="popup-close">×</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -267,6 +392,7 @@
|
|||||||
<div class="popup-description" id="popup-description"></div>
|
<div class="popup-description" id="popup-description"></div>
|
||||||
<div class="popup-calendar" id="popup-calendar"></div>
|
<div class="popup-calendar" id="popup-calendar"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="popup-copy-menu" class="popup-copy-menu hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add CalDAV Account Modal -->
|
<!-- Add CalDAV Account Modal -->
|
||||||
@@ -378,6 +504,49 @@
|
|||||||
</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 style="margin-bottom:16px">
|
||||||
|
<div style="font-size:12px;font-weight:500;text-transform:uppercase;letter-spacing:.5px;color:var(--text-2);margin-bottom:8px">Anmeldemethode</div>
|
||||||
|
<div style="display:flex;gap:20px">
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;color:var(--text-1);font-size:14px">
|
||||||
|
<input type="radio" name="ha-auth-method" value="oauth" id="ha-auth-oauth" checked style="width:auto;accent-color:var(--primary)" /> Mit Home Assistant anmelden
|
||||||
|
</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;color:var(--text-1);font-size:14px">
|
||||||
|
<input type="radio" name="ha-auth-method" value="token" id="ha-auth-token" style="width:auto;accent-color:var(--primary)" /> Long-Lived Token
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="ha-oauth-info" style="margin-bottom:16px;padding:10px 12px;background:var(--bg-app);border-radius:var(--radius-sm);font-size:13px;color:var(--text-2);line-height:1.5">
|
||||||
|
Du wirst zur Login-Seite deiner Home Assistant Instanz weitergeleitet und nach erfolgreichem Login wieder zurück zu Calendarr.
|
||||||
|
</div>
|
||||||
|
<div class="form-group hidden" id="ha-token-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">Mit Home Assistant anmelden</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">
|
||||||
@@ -385,13 +554,17 @@
|
|||||||
<button class="icon-btn modal-close" data-modal="modal-settings" style="margin-right:8px">
|
<button class="icon-btn modal-close" data-modal="modal-settings" style="margin-right:8px">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="icon-btn settings-nav-toggle" id="settings-nav-toggle" title="Menü" aria-label="Menü">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20"><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/></svg>
|
||||||
|
</button>
|
||||||
<h3 data-i18n="settings_title">Einstellungen</h3>
|
<h3 data-i18n="settings_title">Einstellungen</h3>
|
||||||
<button class="btn btn-primary" id="settings-save" style="margin-left:auto" data-i18n="save">Speichern</button>
|
<button class="btn btn-primary" id="settings-save" style="margin-left:auto" data-i18n="save">Speichern</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-page-body">
|
<div class="settings-page-body">
|
||||||
|
<div class="settings-nav-backdrop" id="settings-nav-backdrop"></div>
|
||||||
<nav class="settings-nav">
|
<nav class="settings-nav">
|
||||||
<button class="settings-nav-btn active" data-panel="general" data-i18n="settings_nav_appearance">Darstellung</button>
|
<button class="settings-nav-btn active" data-panel="general" data-i18n="settings_nav_appearance">Darstellung</button>
|
||||||
<button class="settings-nav-btn" data-panel="google" data-i18n="settings_nav_google">Google Konten</button>
|
<button class="settings-nav-btn" data-panel="accounts" data-i18n="settings_nav_accounts">Konten</button>
|
||||||
<button class="settings-nav-btn hidden" data-panel="users" id="settings-nav-users" data-i18n="settings_nav_users">Benutzerverwaltung</button>
|
<button class="settings-nav-btn hidden" data-panel="users" id="settings-nav-users" data-i18n="settings_nav_users">Benutzerverwaltung</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -430,6 +603,20 @@
|
|||||||
<div class="ev-color-preview" id="cfg-today-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
|
<div class="ev-color-preview" id="cfg-today-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="settings_month_divider_color">Monatswechsel-Linie</label>
|
||||||
|
<div class="ev-color-row">
|
||||||
|
<input type="text" id="cfg-month-divider-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
|
||||||
|
<div class="ev-color-preview" id="cfg-month-divider-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="settings_month_label_color">Monatskürzel</label>
|
||||||
|
<div class="ev-color-row">
|
||||||
|
<input type="text" id="cfg-month-label-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
|
||||||
|
<div class="ev-color-preview" id="cfg-month-label-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_text_contrast">Schriftkontrast</h4>
|
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_text_contrast">Schriftkontrast</h4>
|
||||||
<p class="panel-desc" data-i18n="settings_text_contrast_desc">Helligkeit der Beschriftungen und Texte</p>
|
<p class="panel-desc" data-i18n="settings_text_contrast_desc">Helligkeit der Beschriftungen und Texte</p>
|
||||||
@@ -456,6 +643,7 @@
|
|||||||
<option value="month" data-i18n="view_month">Monat</option>
|
<option value="month" data-i18n="view_month">Monat</option>
|
||||||
<option value="week" data-i18n="view_week">Woche</option>
|
<option value="week" data-i18n="view_week">Woche</option>
|
||||||
<option value="day" data-i18n="view_day">Tag</option>
|
<option value="day" data-i18n="view_day">Tag</option>
|
||||||
|
<option value="quarter" data-i18n="view_quarter">Quartal</option>
|
||||||
<option value="agenda" data-i18n="view_agenda">Termine</option>
|
<option value="agenda" data-i18n="view_agenda">Termine</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -476,20 +664,44 @@
|
|||||||
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_hour_height">Stundenhöhe (Wochen- & Tagesansicht)</h4>
|
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_hour_height">Stundenhöhe (Wochen- & Tagesansicht)</h4>
|
||||||
<p class="panel-desc" data-i18n="settings_hour_height_desc">Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt</p>
|
<p class="panel-desc" data-i18n="settings_hour_height_desc">Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt</p>
|
||||||
<div class="contrast-selector" id="cfg-hour-height" data-setting="hour_height">
|
<div class="contrast-selector" id="cfg-hour-height" data-setting="hour_height">
|
||||||
<button class="contrast-btn" data-val="40"><span class="hour-preview">━━</span><span class="contrast-lbl" data-i18n="hour_compact">Kompakt</span></button>
|
<button class="contrast-btn" data-val="28"><span class="hour-preview">━━</span><span class="contrast-lbl" data-i18n="hour_compact">Kompakt</span></button>
|
||||||
<button class="contrast-btn" data-val="60"><span class="hour-preview">━━━</span><span class="contrast-lbl" data-i18n="hour_normal">Normal</span></button>
|
<button class="contrast-btn" data-val="44"><span class="hour-preview">━━━</span><span class="contrast-lbl" data-i18n="hour_normal">Normal</span></button>
|
||||||
<button class="contrast-btn" data-val="80"><span class="hour-preview">━━━━</span><span class="contrast-lbl" data-i18n="hour_comfort">Komfort</span></button>
|
<button class="contrast-btn" data-val="60"><span class="hour-preview">━━━━</span><span class="contrast-lbl" data-i18n="hour_comfort">Komfort</span></button>
|
||||||
<button class="contrast-btn" data-val="100"><span class="hour-preview">━━━━━</span><span class="contrast-lbl" data-i18n="hour_large">Gross</span></button>
|
<button class="contrast-btn" data-val="80"><span class="hour-preview">━━━━━</span><span class="contrast-lbl" data-i18n="hour_large">Gross</span></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_hidden_cals">Ausgeblendete Kalender</h4>
|
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_hidden_cals">Ausgeblendete Kalender</h4>
|
||||||
<div id="hidden-cals-list"><span style="font-size:13px;color:var(--text-3)" data-i18n="settings_no_hidden_cals">Keine ausgeblendeten Kalender</span></div>
|
<div id="hidden-cals-list"><span style="font-size:13px;color:var(--text-3)" data-i18n="settings_no_hidden_cals">Keine ausgeblendeten Kalender</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Google Konten -->
|
<!-- Konten (CalDAV, Lokal, iCal, Google) -->
|
||||||
<div class="settings-panel" id="settings-panel-google">
|
<div class="settings-panel" id="settings-panel-accounts">
|
||||||
<h4 class="panel-title" data-i18n="settings_nav_google">Google Konten</h4>
|
<h4 class="panel-title" data-i18n="settings_nav_accounts">Konten</h4>
|
||||||
<div id="google-accounts-list"><span style="font-size:13px;color:var(--text-3)" data-i18n="settings_no_google">Keine Google-Konten verbunden</span></div>
|
|
||||||
|
<div class="accounts-section">
|
||||||
|
<div class="accounts-section-heading" data-i18n="settings_accounts_caldav">CalDAV-Konten</div>
|
||||||
|
<div id="accounts-caldav-list"><span class="accounts-section-empty" data-i18n="settings_no_caldav_accounts">Keine CalDAV-Konten</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="accounts-section">
|
||||||
|
<div class="accounts-section-heading" data-i18n="settings_accounts_local">Lokale Kalender</div>
|
||||||
|
<div id="accounts-local-list"><span class="accounts-section-empty" data-i18n="settings_no_local_cals">Keine lokalen Kalender</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="accounts-section">
|
||||||
|
<div class="accounts-section-heading" data-i18n="settings_accounts_ical">iCal-Abonnements</div>
|
||||||
|
<div id="accounts-ical-list"><span class="accounts-section-empty" data-i18n="settings_no_ical_subs">Keine Abonnements</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="accounts-section">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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 -->
|
||||||
@@ -591,7 +803,7 @@
|
|||||||
<div class="totp-secret-row">
|
<div class="totp-secret-row">
|
||||||
<code id="2fa-secret-code"></code>
|
<code id="2fa-secret-code"></code>
|
||||||
<button class="btn btn-ghost btn-sm" id="2fa-copy-secret" title="Kopieren">
|
<button class="btn btn-ghost btn-sm" id="2fa-copy-secret" title="Kopieren">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M16 1H4c-1.1 0-2 .9-2 2v24h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v24c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v24z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -646,6 +858,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Toast -->
|
<!-- Toast -->
|
||||||
|
<!-- Floating action button (mobile only — create event) -->
|
||||||
|
<button id="btn-create-fab" class="create-fab" title="Termin erstellen" aria-label="Termin erstellen">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" width="28" height="28"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div id="toast" class="toast hidden"></div>
|
<div id="toast" class="toast hidden"></div>
|
||||||
|
|
||||||
<!-- Impressum Modal -->
|
<!-- Impressum Modal -->
|
||||||
@@ -660,13 +877,14 @@
|
|||||||
Software & Webentwicklung</p>
|
Software & Webentwicklung</p>
|
||||||
<p>Diese Software wurde von Scarriffleservices mit grösster Sorgfalt entwickelt und bereitgestellt. Alle Rechte vorbehalten © 2026 Scarriffleservices.</p>
|
<p>Diese Software wurde von Scarriffleservices mit grösster Sorgfalt entwickelt und bereitgestellt. Alle Rechte vorbehalten © 2026 Scarriffleservices.</p>
|
||||||
<p><strong>Datenspeicherung</strong><br>
|
<p><strong>Datenspeicherung</strong><br>
|
||||||
Alle Anwendungsdaten werden ausschliesslich in der Schweiz gespeichert und verarbeitet. Bei Nutzung der Google Kalender-Anbindung werden Daten über die Google API abgerufen, welche von Google auf deren Infrastruktur ausserhalb der Schweiz verarbeitet werden. Für diese Daten gelten die Datenschutzbestimmungen von Google.</p>
|
Alle Anwendungsdaten werden auf dem Server gespeichert und verarbeitet, auf dem diese Calendarr-Instanz betrieben wird. Der Speicherort hängt damit vom Betreiber des jeweiligen Servers ab. Bei Nutzung der Google Kalender-Anbindung werden Daten über die Google API ausgetauscht; für diese Daten gelten die Datenschutzbestimmungen von Google. Bei Nutzung der Home Assistant-Anbindung werden Daten mit der jeweiligen Home Assistant-Instanz ausgetauscht. Home Assistant ist ein Projekt der Open Home Foundation.</p>
|
||||||
<p><strong>Haftungsausschluss</strong><br>
|
<p><strong>Haftungsausschluss</strong><br>
|
||||||
Trotz sorgfältiger Erstellung wird keine Haftung für die Richtigkeit, Vollständigkeit oder Aktualität der bereitgestellten Inhalte übernommen. Die Nutzung erfolgt auf eigene Verantwortung.</p>
|
Trotz sorgfältiger Erstellung wird keine Haftung für die Richtigkeit, Vollständigkeit oder Aktualität der bereitgestellten Inhalte übernommen. Die Nutzung erfolgt auf eigene Verantwortung.</p>
|
||||||
<p><strong>Kontakt</strong><br>
|
<p><strong>Kontakt</strong><br>
|
||||||
<a href="mailto:scarriffleservices@gmail.com">scarriffleservices@gmail.com</a></p>
|
<a href="mailto:scarriffleservices@gmail.com">scarriffleservices@gmail.com</a></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer" style="justify-content:space-between;align-items:center">
|
||||||
|
<span style="font-size:12px;color:var(--text-3)">Calendarr v11</span>
|
||||||
<button class="btn btn-ghost" onclick="closeImpressum()">Schliessen</button>
|
<button class="btn btn-ghost" onclick="closeImpressum()">Schliessen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ export const api = {
|
|||||||
delete: (path) => request('DELETE', path),
|
delete: (path) => request('DELETE', path),
|
||||||
upload: (path, form) => uploadRequest(path, form),
|
upload: (path, form) => uploadRequest(path, form),
|
||||||
|
|
||||||
login: (username, password, totp_code = null) =>
|
login: (username, password, totp_code = null, remember_me = false) =>
|
||||||
request('POST', '/auth/login', { username, password, totp_code }),
|
request('POST', '/auth/login', { username, password, totp_code, remember_me }),
|
||||||
|
|
||||||
setupRequired: () => request('GET', '/auth/setup-required'),
|
setupRequired: () => request('GET', '/auth/setup-required'),
|
||||||
setup: (data) => request('POST', '/auth/setup', data),
|
setup: (data) => request('POST', '/auth/setup', data),
|
||||||
|
|||||||
@@ -141,11 +141,12 @@ function bindLoginForm() {
|
|||||||
const username = document.getElementById('login-username').value.trim();
|
const username = document.getElementById('login-username').value.trim();
|
||||||
const password = document.getElementById('login-password').value;
|
const password = document.getElementById('login-password').value;
|
||||||
const totpCode = document.getElementById('login-totp')?.value.trim() || null;
|
const totpCode = document.getElementById('login-totp')?.value.trim() || null;
|
||||||
|
const remember = document.getElementById('login-remember')?.checked || false;
|
||||||
const errEl = document.getElementById('login-error');
|
const errEl = document.getElementById('login-error');
|
||||||
errEl.classList.add('hidden');
|
errEl.classList.add('hidden');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api.login(username, password, totpCode);
|
const res = await api.login(username, password, totpCode, remember);
|
||||||
localStorage.setItem('token', res.access_token);
|
localStorage.setItem('token', res.access_token);
|
||||||
localStorage.setItem('user', JSON.stringify(res.user));
|
localStorage.setItem('user', JSON.stringify(res.user));
|
||||||
await launchApp();
|
await launchApp();
|
||||||
@@ -188,3 +189,12 @@ function loadAvatarImage(avatarEl, username) {
|
|||||||
|
|
||||||
// ── Start ─────────────────────────────────────────────────
|
// ── Start ─────────────────────────────────────────────────
|
||||||
boot();
|
boot();
|
||||||
|
|
||||||
|
// ── Service Worker registration (PWA) ─────────────────────
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(err => {
|
||||||
|
console.warn('SW registration failed:', err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -128,11 +128,16 @@ export function openColorPicker(anchorEl, currentColor = '#4285f4') {
|
|||||||
function updateUI() {
|
function updateUI() {
|
||||||
const [r, g, b] = hsvToRgb(h, s, v);
|
const [r, g, b] = hsvToRgb(h, s, v);
|
||||||
const hex = rgbToHex(r, g, b);
|
const hex = rgbToHex(r, g, b);
|
||||||
// SV cursor position
|
// Use rendered rects to position cursor relative to the picker container
|
||||||
svCursor.style.left = (s * svCanvas.width) + 'px';
|
const svRect = svCanvas.getBoundingClientRect();
|
||||||
svCursor.style.top = ((1 - v) * svCanvas.height) + 'px';
|
const pickerRect = picker.getBoundingClientRect();
|
||||||
// Hue thumb position
|
const hueRect = hueCanvas.getBoundingClientRect();
|
||||||
hueThumb.style.left = (h / 360 * hueCanvas.width) + 'px';
|
const hueTrackRect = hueTrack.getBoundingClientRect();
|
||||||
|
// SV cursor: offset canvas position within picker + position within canvas
|
||||||
|
svCursor.style.left = (svRect.left - pickerRect.left + s * svRect.width) + 'px';
|
||||||
|
svCursor.style.top = (svRect.top - pickerRect.top + (1 - v) * svRect.height) + 'px';
|
||||||
|
// Hue thumb: offset canvas position within track + position within canvas
|
||||||
|
hueThumb.style.left = (hueRect.left - hueTrackRect.left + (h / 360) * hueRect.width) + 'px';
|
||||||
// Preview + hex
|
// Preview + hex
|
||||||
preview.style.background = hex;
|
preview.style.background = hex;
|
||||||
hexInput.value = hex.toUpperCase();
|
hexInput.value = hex.toUpperCase();
|
||||||
|
|||||||
271
frontend/js/date-picker.js
Normal file
271
frontend/js/date-picker.js
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
/**
|
||||||
|
* Custom dark date/time picker
|
||||||
|
* openDatePicker(anchor, value, mode) → Promise<string|null>
|
||||||
|
* anchor : DOM element to position near
|
||||||
|
* value : ISO string ("YYYY-MM-DDTHH:MM" | "YYYY-MM-DD") or ""
|
||||||
|
* mode : 'datetime' | 'date'
|
||||||
|
*/
|
||||||
|
import { t } from './i18n.js';
|
||||||
|
|
||||||
|
const ITEM_H = 40; // px per scroll item
|
||||||
|
const VISIBLE = 3; // visible items in time scroller
|
||||||
|
|
||||||
|
export function openDatePicker(anchor, value, mode = 'datetime') {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
// ── Parse initial value ───────────────────────────────
|
||||||
|
let selDate = new Date();
|
||||||
|
selDate.setHours(0, 0, 0, 0);
|
||||||
|
let selHour = selDate.getHours();
|
||||||
|
let selMin = 0;
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
try {
|
||||||
|
const raw = mode === 'datetime'
|
||||||
|
? value.replace(' ', 'T')
|
||||||
|
: value + 'T00:00:00';
|
||||||
|
const d = new Date(raw);
|
||||||
|
if (!isNaN(d)) {
|
||||||
|
selDate = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||||
|
if (mode === 'datetime') { selHour = d.getHours(); selMin = d.getMinutes(); }
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let viewYear = selDate.getFullYear();
|
||||||
|
let viewMonth = selDate.getMonth();
|
||||||
|
|
||||||
|
// ── Build DOM ─────────────────────────────────────────
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'dtp-overlay';
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'dtp-card';
|
||||||
|
overlay.appendChild(card);
|
||||||
|
|
||||||
|
function done(result) {
|
||||||
|
overlay.remove();
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click outside → cancel
|
||||||
|
overlay.addEventListener('mousedown', e => {
|
||||||
|
if (e.target === overlay) done(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Calendar builder ──────────────────────────────────
|
||||||
|
function buildCalendar() {
|
||||||
|
const months = t('months');
|
||||||
|
const dowKeys = t('dow_monday'); // always Monday-first in calendar
|
||||||
|
|
||||||
|
const firstDay = new Date(viewYear, viewMonth, 1);
|
||||||
|
const gridStart = new Date(firstDay);
|
||||||
|
let dow = firstDay.getDay();
|
||||||
|
dow = dow === 0 ? 6 : dow - 1; // 0=Mon…6=Sun
|
||||||
|
gridStart.setDate(gridStart.getDate() - dow);
|
||||||
|
|
||||||
|
const cells = [];
|
||||||
|
const iter = new Date(gridStart);
|
||||||
|
for (let i = 0; i < 42; i++) {
|
||||||
|
cells.push(new Date(iter));
|
||||||
|
iter.setDate(iter.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const dowHtml = dowKeys.map(d => `<div class="dtp-dow">${d}</div>`).join('');
|
||||||
|
const daysHtml = cells.map(cell => {
|
||||||
|
const isOther = cell.getMonth() !== viewMonth;
|
||||||
|
const isToday = cell.getTime() === today.getTime();
|
||||||
|
const isSelected = cell.getTime() === selDate.getTime();
|
||||||
|
let cls = 'dtp-day';
|
||||||
|
if (isOther) cls += ' other';
|
||||||
|
if (isToday) cls += ' today';
|
||||||
|
if (isSelected) cls += ' selected';
|
||||||
|
return `<div class="${cls}" data-ts="${cell.getTime()}">${cell.getDate()}</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return `<div class="dtp-cal-header">
|
||||||
|
<button class="dtp-nav-btn" id="dtp-prev">‹</button>
|
||||||
|
<span class="dtp-month-label">${months[viewMonth]} ${viewYear}</span>
|
||||||
|
<button class="dtp-nav-btn" id="dtp-next">›</button>
|
||||||
|
</div>
|
||||||
|
<div class="dtp-grid">
|
||||||
|
${dowHtml}
|
||||||
|
${daysHtml}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Time scroll builder ───────────────────────────────
|
||||||
|
function buildTime() {
|
||||||
|
if (mode !== 'datetime') return '';
|
||||||
|
const hItems = Array.from({ length: 24 }, (_, i) =>
|
||||||
|
`<div class="dtp-ti" data-val="${i}">${String(i).padStart(2,'0')}</div>`
|
||||||
|
).join('');
|
||||||
|
const mItems = Array.from({ length: 60 }, (_, i) =>
|
||||||
|
`<div class="dtp-ti" data-val="${i}">${String(i).padStart(2,'0')}</div>`
|
||||||
|
).join('');
|
||||||
|
return `<div class="dtp-time-row">
|
||||||
|
<div class="dtp-tc-wrap">
|
||||||
|
<div class="dtp-tc" id="dtp-h">${hItems}</div>
|
||||||
|
</div>
|
||||||
|
<div class="dtp-colon">:</div>
|
||||||
|
<div class="dtp-tc-wrap">
|
||||||
|
<div class="dtp-tc" id="dtp-m">${mItems}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────
|
||||||
|
function render() {
|
||||||
|
card.innerHTML =
|
||||||
|
buildCalendar() +
|
||||||
|
buildTime() +
|
||||||
|
`<div class="dtp-actions">
|
||||||
|
<button class="btn btn-ghost btn-sm" id="dtp-cancel">${t('cancel')}</button>
|
||||||
|
<button class="btn btn-primary btn-sm" id="dtp-ok">${t('save')}</button>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
bindEvents();
|
||||||
|
if (mode === 'datetime') initScrollers();
|
||||||
|
positionCard();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event bindings ────────────────────────────────────
|
||||||
|
function bindEvents() {
|
||||||
|
card.querySelector('#dtp-prev').onclick = () => {
|
||||||
|
viewMonth--;
|
||||||
|
if (viewMonth < 0) { viewMonth = 11; viewYear--; }
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
card.querySelector('#dtp-next').onclick = () => {
|
||||||
|
viewMonth++;
|
||||||
|
if (viewMonth > 11) { viewMonth = 0; viewYear++; }
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Day click
|
||||||
|
card.querySelectorAll('.dtp-day').forEach(el => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
selDate = new Date(parseInt(el.dataset.ts));
|
||||||
|
if (el.classList.contains('other')) {
|
||||||
|
viewYear = selDate.getFullYear();
|
||||||
|
viewMonth = selDate.getMonth();
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
card.querySelector('#dtp-cancel').onclick = () => done(null);
|
||||||
|
card.querySelector('#dtp-ok').onclick = () => done(buildResult());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Time scroll initialisation ────────────────────────
|
||||||
|
function initScrollers() {
|
||||||
|
const hCol = card.querySelector('#dtp-h');
|
||||||
|
const mCol = card.querySelector('#dtp-m');
|
||||||
|
if (!hCol || !mCol) return;
|
||||||
|
|
||||||
|
// Scroll to selected value (padding-top = ITEM_H, so scrollTop = val * ITEM_H)
|
||||||
|
hCol.scrollTop = selHour * ITEM_H;
|
||||||
|
mCol.scrollTop = selMin * ITEM_H;
|
||||||
|
highlightItems(hCol, selHour);
|
||||||
|
highlightItems(mCol, selMin);
|
||||||
|
|
||||||
|
let hTimer, mTimer;
|
||||||
|
|
||||||
|
hCol.addEventListener('scroll', () => {
|
||||||
|
clearTimeout(hTimer);
|
||||||
|
hTimer = setTimeout(() => {
|
||||||
|
selHour = Math.max(0, Math.min(23, Math.round(hCol.scrollTop / ITEM_H)));
|
||||||
|
hCol.scrollTop = selHour * ITEM_H;
|
||||||
|
highlightItems(hCol, selHour);
|
||||||
|
}, 80);
|
||||||
|
});
|
||||||
|
|
||||||
|
mCol.addEventListener('scroll', () => {
|
||||||
|
clearTimeout(mTimer);
|
||||||
|
mTimer = setTimeout(() => {
|
||||||
|
selMin = Math.max(0, Math.min(59, Math.round(mCol.scrollTop / ITEM_H)));
|
||||||
|
mCol.scrollTop = selMin * ITEM_H;
|
||||||
|
highlightItems(mCol, selMin);
|
||||||
|
}, 80);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click item to select
|
||||||
|
hCol.querySelectorAll('.dtp-ti').forEach((el, i) => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
selHour = i;
|
||||||
|
hCol.scrollTo({ top: i * ITEM_H, behavior: 'smooth' });
|
||||||
|
highlightItems(hCol, i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
mCol.querySelectorAll('.dtp-ti').forEach((el, i) => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
selMin = i;
|
||||||
|
mCol.scrollTo({ top: i * ITEM_H, behavior: 'smooth' });
|
||||||
|
highlightItems(mCol, i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightItems(col, val) {
|
||||||
|
col.querySelectorAll('.dtp-ti').forEach((el, i) => {
|
||||||
|
el.classList.toggle('selected', i === val);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Result builder ────────────────────────────────────
|
||||||
|
function buildResult() {
|
||||||
|
const y = selDate.getFullYear();
|
||||||
|
const mo = String(selDate.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(selDate.getDate()).padStart(2, '0');
|
||||||
|
if (mode === 'date') return `${y}-${mo}-${dd}`;
|
||||||
|
const h = String(selHour).padStart(2, '0');
|
||||||
|
const mi = String(selMin).padStart(2, '0');
|
||||||
|
return `${y}-${mo}-${dd}T${h}:${mi}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Positioning ───────────────────────────────────────
|
||||||
|
function positionCard() {
|
||||||
|
const r = anchor.getBoundingClientRect();
|
||||||
|
const cw = card.offsetWidth || 280;
|
||||||
|
const ch = card.offsetHeight || 420;
|
||||||
|
let left = r.left;
|
||||||
|
let top = r.bottom + 6;
|
||||||
|
if (left + cw > window.innerWidth - 8) left = window.innerWidth - cw - 8;
|
||||||
|
if (top + ch > window.innerHeight - 8) top = r.top - ch - 6;
|
||||||
|
if (left < 8) left = 8;
|
||||||
|
if (top < 8) top = 8;
|
||||||
|
card.style.left = left + 'px';
|
||||||
|
card.style.top = top + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an ISO value for display in the UI
|
||||||
|
* mode: 'datetime' | 'date'
|
||||||
|
* lang: 'de' | 'en'
|
||||||
|
*/
|
||||||
|
export function formatDtDisplay(isoStr, mode, lang = 'de') {
|
||||||
|
if (!isoStr) return '—';
|
||||||
|
try {
|
||||||
|
const d = mode === 'datetime'
|
||||||
|
? new Date(isoStr.replace(' ', 'T'))
|
||||||
|
: new Date(isoStr + 'T00:00:00');
|
||||||
|
if (isNaN(d)) return isoStr;
|
||||||
|
const locale = lang === 'en' ? 'en-GB' : 'de-CH';
|
||||||
|
if (mode === 'datetime') {
|
||||||
|
return d.toLocaleString(locale, {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return d.toLocaleDateString(locale, {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
|
});
|
||||||
|
} catch (_) { return isoStr; }
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ const translations = {
|
|||||||
|
|
||||||
// Topbar
|
// Topbar
|
||||||
btn_today: 'Heute',
|
btn_today: 'Heute',
|
||||||
view_month: 'Monat', view_week: 'Woche', view_day: 'Tag', view_agenda: 'Termine',
|
view_month: 'Monat', view_week: 'Woche', view_day: 'Tag', view_quarter: 'Quartal', view_agenda: 'Termine',
|
||||||
btn_profile: 'Profil', btn_logout: 'Abmelden',
|
btn_profile: 'Profil', btn_logout: 'Abmelden',
|
||||||
|
|
||||||
// Sidebar
|
// Sidebar
|
||||||
@@ -65,6 +65,8 @@ const translations = {
|
|||||||
settings_colors: 'Farben',
|
settings_colors: 'Farben',
|
||||||
settings_primary_color: 'Primärfarbe', settings_accent_color: 'Akzentfarbe',
|
settings_primary_color: 'Primärfarbe', settings_accent_color: 'Akzentfarbe',
|
||||||
settings_today_color: 'Heutige-Tag-Farbe',
|
settings_today_color: 'Heutige-Tag-Farbe',
|
||||||
|
settings_month_divider_color: 'Monatswechsel-Linie',
|
||||||
|
settings_month_label_color: 'Monatskürzel-Farbe',
|
||||||
settings_text_contrast: 'Schriftkontrast',
|
settings_text_contrast: 'Schriftkontrast',
|
||||||
settings_text_contrast_desc: 'Helligkeit der Beschriftungen und Texte',
|
settings_text_contrast_desc: 'Helligkeit der Beschriftungen und Texte',
|
||||||
contrast_dark: 'Dunkel', contrast_medium: 'Mittel',
|
contrast_dark: 'Dunkel', contrast_medium: 'Mittel',
|
||||||
@@ -86,6 +88,17 @@ const translations = {
|
|||||||
settings_hidden_cals: 'Ausgeblendete Kalender',
|
settings_hidden_cals: 'Ausgeblendete Kalender',
|
||||||
settings_no_hidden_cals: 'Keine ausgeblendeten Kalender',
|
settings_no_hidden_cals: 'Keine ausgeblendeten Kalender',
|
||||||
settings_no_google: 'Keine Google-Konten verbunden',
|
settings_no_google: 'Keine Google-Konten verbunden',
|
||||||
|
settings_nav_accounts: 'Konten',
|
||||||
|
settings_accounts_caldav: 'CalDAV-Konten',
|
||||||
|
settings_accounts_local: 'Lokale Kalender',
|
||||||
|
settings_accounts_ical: 'iCal-Abonnements',
|
||||||
|
settings_accounts_google: 'Google-Konten',
|
||||||
|
settings_no_caldav_accounts: 'Keine CalDAV-Konten',
|
||||||
|
settings_no_local_cals: 'Keine lokalen Kalender',
|
||||||
|
settings_no_ical_subs: 'Keine Abonnements',
|
||||||
|
settings_no_google_accounts: 'Keine Google-Konten',
|
||||||
|
confirm_caldav_disconnect: 'CalDAV-Konto wirklich trennen?',
|
||||||
|
caldav_disconnected: 'CalDAV-Konto getrennt',
|
||||||
|
|
||||||
// User management
|
// User management
|
||||||
users_add: 'Benutzer hinzufügen', users_is_admin: 'Administrator',
|
users_add: 'Benutzer hinzufügen', users_is_admin: 'Administrator',
|
||||||
@@ -117,7 +130,7 @@ const translations = {
|
|||||||
impressum_desc: 'Software & Webentwicklung',
|
impressum_desc: 'Software & Webentwicklung',
|
||||||
impressum_about: 'Diese Software wurde von Scarriffleservices mit grösster Sorgfalt entwickelt und bereitgestellt. Alle Rechte vorbehalten\u00a0© 2026 Scarriffleservices.',
|
impressum_about: 'Diese Software wurde von Scarriffleservices mit grösster Sorgfalt entwickelt und bereitgestellt. Alle Rechte vorbehalten\u00a0© 2026 Scarriffleservices.',
|
||||||
impressum_data_title: 'Datenspeicherung',
|
impressum_data_title: 'Datenspeicherung',
|
||||||
impressum_data_text: 'Alle Anwendungsdaten werden ausschliesslich in der Schweiz gespeichert und verarbeitet. Bei Nutzung der Google Kalender-Anbindung werden Daten über die Google API abgerufen, welche von Google auf deren Infrastruktur ausserhalb der Schweiz verarbeitet werden. Für diese Daten gelten die Datenschutzbestimmungen von Google.',
|
impressum_data_text: 'Alle Anwendungsdaten werden auf dem Server gespeichert und verarbeitet, auf dem diese Calendarr-Instanz betrieben wird. Der Speicherort hängt damit vom Betreiber des jeweiligen Servers ab. Bei Nutzung der Google Kalender-Anbindung werden Daten über die Google API ausgetauscht; für diese Daten gelten die Datenschutzbestimmungen von Google. Bei Nutzung der Home Assistant-Anbindung werden Daten mit der jeweiligen Home Assistant-Instanz ausgetauscht. Home Assistant ist ein Projekt der Open Home Foundation.',
|
||||||
impressum_disclaimer_title: 'Haftungsausschluss',
|
impressum_disclaimer_title: 'Haftungsausschluss',
|
||||||
impressum_disclaimer_text: 'Trotz sorgfältiger Erstellung wird keine Haftung für die Richtigkeit, Vollständigkeit oder Aktualität der bereitgestellten Inhalte übernommen. Die Nutzung erfolgt auf eigene Verantwortung.',
|
impressum_disclaimer_text: 'Trotz sorgfältiger Erstellung wird keine Haftung für die Richtigkeit, Vollständigkeit oder Aktualität der bereitgestellten Inhalte übernommen. Die Nutzung erfolgt auf eigene Verantwortung.',
|
||||||
impressum_contact: 'Kontakt',
|
impressum_contact: 'Kontakt',
|
||||||
@@ -131,8 +144,23 @@ 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',
|
||||||
|
ha_create_not_supported: 'Termine können in Home Assistant Kalendern nicht direkt erstellt 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',
|
||||||
|
edit_before_copy: 'Vor dem Kopieren bearbeiten',
|
||||||
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?',
|
||||||
|
confirm_delete_title: 'Termin löschen',
|
||||||
|
delete_single: 'Nur diesen Termin',
|
||||||
|
delete_all_series: 'Alle Termine der Serie',
|
||||||
event_deleted: 'Termin gelöscht',
|
event_deleted: 'Termin gelöscht',
|
||||||
error_fill_all: 'Bitte alle Felder ausfüllen',
|
error_fill_all: 'Bitte alle Felder ausfüllen',
|
||||||
account_added: 'Konto "{name}" hinzugefügt',
|
account_added: 'Konto "{name}" hinzugefügt',
|
||||||
@@ -207,7 +235,7 @@ const translations = {
|
|||||||
|
|
||||||
// Topbar
|
// Topbar
|
||||||
btn_today: 'Today',
|
btn_today: 'Today',
|
||||||
view_month: 'Month', view_week: 'Week', view_day: 'Day', view_agenda: 'Events',
|
view_month: 'Month', view_week: 'Week', view_day: 'Day', view_quarter: 'Quarter', view_agenda: 'Events',
|
||||||
btn_profile: 'Profile', btn_logout: 'Log out',
|
btn_profile: 'Profile', btn_logout: 'Log out',
|
||||||
|
|
||||||
// Sidebar
|
// Sidebar
|
||||||
@@ -248,6 +276,8 @@ const translations = {
|
|||||||
settings_colors: 'Colors',
|
settings_colors: 'Colors',
|
||||||
settings_primary_color: 'Primary color', settings_accent_color: 'Accent color',
|
settings_primary_color: 'Primary color', settings_accent_color: 'Accent color',
|
||||||
settings_today_color: 'Today highlight color',
|
settings_today_color: 'Today highlight color',
|
||||||
|
settings_month_divider_color: 'Month divider line',
|
||||||
|
settings_month_label_color: 'Month label color',
|
||||||
settings_text_contrast: 'Text contrast',
|
settings_text_contrast: 'Text contrast',
|
||||||
settings_text_contrast_desc: 'Brightness of labels and text',
|
settings_text_contrast_desc: 'Brightness of labels and text',
|
||||||
contrast_dark: 'Dark', contrast_medium: 'Medium',
|
contrast_dark: 'Dark', contrast_medium: 'Medium',
|
||||||
@@ -269,6 +299,17 @@ const translations = {
|
|||||||
settings_hidden_cals: 'Hidden calendars',
|
settings_hidden_cals: 'Hidden calendars',
|
||||||
settings_no_hidden_cals: 'No hidden calendars',
|
settings_no_hidden_cals: 'No hidden calendars',
|
||||||
settings_no_google: 'No Google accounts connected',
|
settings_no_google: 'No Google accounts connected',
|
||||||
|
settings_nav_accounts: 'Accounts',
|
||||||
|
settings_accounts_caldav: 'CalDAV Accounts',
|
||||||
|
settings_accounts_local: 'Local Calendars',
|
||||||
|
settings_accounts_ical: 'iCal Subscriptions',
|
||||||
|
settings_accounts_google: 'Google Accounts',
|
||||||
|
settings_no_caldav_accounts: 'No CalDAV accounts',
|
||||||
|
settings_no_local_cals: 'No local calendars',
|
||||||
|
settings_no_ical_subs: 'No subscriptions',
|
||||||
|
settings_no_google_accounts: 'No Google accounts',
|
||||||
|
confirm_caldav_disconnect: 'Really disconnect CalDAV account?',
|
||||||
|
caldav_disconnected: 'CalDAV account disconnected',
|
||||||
|
|
||||||
// User management
|
// User management
|
||||||
users_add: 'Add user', users_is_admin: 'Administrator',
|
users_add: 'Add user', users_is_admin: 'Administrator',
|
||||||
@@ -300,7 +341,7 @@ const translations = {
|
|||||||
impressum_desc: 'Software & Web Development',
|
impressum_desc: 'Software & Web Development',
|
||||||
impressum_about: 'This software was developed and provided by Scarriffleservices with the utmost care. All rights reserved\u00a0© 2026 Scarriffleservices.',
|
impressum_about: 'This software was developed and provided by Scarriffleservices with the utmost care. All rights reserved\u00a0© 2026 Scarriffleservices.',
|
||||||
impressum_data_title: 'Data Storage',
|
impressum_data_title: 'Data Storage',
|
||||||
impressum_data_text: 'All application data is stored and processed exclusively in Switzerland. When using the Google Calendar integration, data is retrieved via the Google API, which Google processes on their infrastructure outside Switzerland. Google\'s privacy policy applies to this data.',
|
impressum_data_text: 'All application data is stored and processed on the server hosting this Calendarr instance. The storage location therefore depends on whoever operates that server. When using the Google Calendar integration, data is exchanged via the Google API; Google\'s privacy policy applies to this data. When using the Home Assistant integration, data is exchanged with the respective Home Assistant instance. Home Assistant is a project of the Open Home Foundation.',
|
||||||
impressum_disclaimer_title: 'Disclaimer',
|
impressum_disclaimer_title: 'Disclaimer',
|
||||||
impressum_disclaimer_text: 'Despite careful preparation, no liability is assumed for the accuracy, completeness or timeliness of the content provided. Use is at your own risk.',
|
impressum_disclaimer_text: 'Despite careful preparation, no liability is assumed for the accuracy, completeness or timeliness of the content provided. Use is at your own risk.',
|
||||||
impressum_contact: 'Contact',
|
impressum_contact: 'Contact',
|
||||||
@@ -314,8 +355,23 @@ 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',
|
||||||
|
ha_create_not_supported: 'Events cannot be created directly in Home Assistant calendars',
|
||||||
|
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',
|
||||||
|
edit_before_copy: 'Edit before copying',
|
||||||
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}"?',
|
||||||
|
confirm_delete_title: 'Delete event',
|
||||||
|
delete_single: 'Only this occurrence',
|
||||||
|
delete_all_series: 'All events in series',
|
||||||
event_deleted: 'Event deleted',
|
event_deleted: 'Event deleted',
|
||||||
error_fill_all: 'Please fill in all fields',
|
error_fill_all: 'Please fill in all fields',
|
||||||
account_added: 'Account "{name}" added',
|
account_added: 'Account "{name}" added',
|
||||||
|
|||||||
@@ -92,8 +92,11 @@ export function applyTheme(settings) {
|
|||||||
root.style.setProperty('--border', lc.border);
|
root.style.setProperty('--border', lc.border);
|
||||||
root.style.setProperty('--border-light', lc.light);
|
root.style.setProperty('--border-light', lc.light);
|
||||||
|
|
||||||
const hh = settings.hour_height || 60;
|
const hh = settings.hour_height || 44;
|
||||||
root.style.setProperty('--hour-h', hh + 'px');
|
root.style.setProperty('--hour-h', hh + 'px');
|
||||||
|
|
||||||
|
root.style.setProperty('--month-divider-color', settings.month_divider_color || '#7090c0');
|
||||||
|
root.style.setProperty('--month-label-color', settings.month_label_color || '#7090c0');
|
||||||
}
|
}
|
||||||
|
|
||||||
function hexToRgba(hex, alpha) {
|
function hexToRgba(hex, alpha) {
|
||||||
|
|||||||
2
frontend/js/version.js
Normal file
2
frontend/js/version.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Increment APP_VERSION with every code change
|
||||||
|
export const APP_VERSION = 'v11';
|
||||||
@@ -1,119 +1,231 @@
|
|||||||
import { formatDate, isSameDay, isToday, isPast, dayOfWeek, getISOWeekNumber } from '../utils.js';
|
import { isToday, isPast, isSameDay, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js';
|
||||||
import { t } from '../i18n.js';
|
import { t } from '../i18n.js';
|
||||||
|
|
||||||
export function renderMonth(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') {
|
const LANE_H = 20; // px per lane (event height 18px + 2px gap)
|
||||||
const year = currentDate.getFullYear();
|
const DAY_H = 30; // day-number row height
|
||||||
const month = currentDate.getMonth();
|
const NUM_ROWS = 5; // rolling view: always 5 weeks
|
||||||
|
|
||||||
|
export function renderMonth(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday', selectedDate = null) {
|
||||||
|
// Dynamic lane limit: how many events fit in the actual row height
|
||||||
|
const containerH = container.clientHeight || 600;
|
||||||
|
const headerH = 34; // month-header DOW row
|
||||||
|
const rowH = (containerH - headerH) / NUM_ROWS;
|
||||||
|
const MAX_LANES = Math.max(1, Math.floor((rowH - DAY_H) / LANE_H) - 1);
|
||||||
|
// "Primary month" = currentDate's month (used for muting other-month days)
|
||||||
|
const primaryMonth = currentDate.getMonth();
|
||||||
const DOW = weekStartDay === 'sunday' ? t('dow_sunday') : t('dow_monday');
|
const DOW = weekStartDay === 'sunday' ? t('dow_sunday') : t('dow_monday');
|
||||||
|
|
||||||
const firstDay = new Date(year, month, 1);
|
// Rolling grid: start at the week that contains currentDate
|
||||||
const lastDay = new Date(year, month + 1, 0);
|
const gridStart = weekStart(currentDate, weekStartDay);
|
||||||
|
|
||||||
// Start grid on the correct weekday
|
|
||||||
const gridStart = new Date(firstDay);
|
|
||||||
const offset = dayOfWeek(firstDay, weekStartDay);
|
|
||||||
gridStart.setDate(gridStart.getDate() - offset);
|
|
||||||
|
|
||||||
|
// Build NUM_ROWS × 7 cells
|
||||||
const cells = [];
|
const cells = [];
|
||||||
const d = new Date(gridStart);
|
const d = new Date(gridStart);
|
||||||
for (let i = 0; i < 42; i++) {
|
for (let i = 0; i < NUM_ROWS * 7; i++) {
|
||||||
cells.push(new Date(d));
|
cells.push(new Date(d));
|
||||||
d.setDate(d.getDate() + 1);
|
d.setDate(d.getDate() + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build event map keyed by date string
|
// Normalize each event's date range once
|
||||||
const evMap = {};
|
const normed = events.map(ev => {
|
||||||
events.forEach(ev => {
|
const s = new Date(ev.start); s.setHours(0, 0, 0, 0);
|
||||||
const s = new Date(ev.start);
|
const e = new Date(ev.end); e.setHours(0, 0, 0, 0);
|
||||||
const e = ev.allDay ? new Date(ev.end) : new Date(ev.end);
|
if (ev.allDay && e > s) e.setDate(e.getDate() - 1); // exclusive → inclusive
|
||||||
// Spread multi-day events across cells
|
return { ev, ns: s, ne: e };
|
||||||
const cur = new Date(s);
|
|
||||||
cur.setHours(0, 0, 0, 0);
|
|
||||||
const endNorm = new Date(e);
|
|
||||||
endNorm.setHours(0, 0, 0, 0);
|
|
||||||
if (ev.allDay && endNorm > cur) endNorm.setDate(endNorm.getDate() - 1);
|
|
||||||
while (cur <= endNorm) {
|
|
||||||
const key = dateKey(cur);
|
|
||||||
if (!evMap[key]) evMap[key] = [];
|
|
||||||
evMap[key].push(ev);
|
|
||||||
cur.setDate(cur.getDate() + 1);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Header: KW-Spalte + Wochentage
|
// Header
|
||||||
const headerHtml = `<div class="month-kw-header">KW</div>` +
|
const headerHtml =
|
||||||
|
`<div class="month-kw-header">KW</div>` +
|
||||||
DOW.map(d => `<div class="month-dow">${d}</div>`).join('');
|
DOW.map(d => `<div class="month-dow">${d}</div>`).join('');
|
||||||
|
|
||||||
// Build rows (6 weeks × 7 days)
|
// Build rows
|
||||||
let cellsHtml = '';
|
let bodyHtml = '';
|
||||||
for (let row = 0; row < 6; row++) {
|
for (let row = 0; row < NUM_ROWS; row++) {
|
||||||
// KW cell for the first day of this row
|
const rowCells = cells.slice(row * 7, row * 7 + 7);
|
||||||
const rowFirstDay = cells[row * 7];
|
const rowStart = new Date(rowCells[0]); rowStart.setHours(0, 0, 0, 0);
|
||||||
const kw = getISOWeekNumber(rowFirstDay);
|
const rowEnd = new Date(rowCells[6]); rowEnd.setHours(0, 0, 0, 0);
|
||||||
cellsHtml += `<div class="month-kw-cell">${kw}</div>`;
|
const kw = getISOWeekNumber(rowCells[0]);
|
||||||
|
|
||||||
for (let col = 0; col < 7; col++) {
|
// Collect events overlapping this row
|
||||||
const cell = cells[row * 7 + col];
|
const rowItems = [];
|
||||||
const key = dateKey(cell);
|
normed.forEach(({ ev, ns, ne }) => {
|
||||||
const cellEvs = (evMap[key] || []).slice().sort((a, b) => {
|
if (ne < rowStart || ns > rowEnd) return;
|
||||||
if (a.allDay && !b.allDay) return -1;
|
const colStart = Math.max(0, daysBetween(rowStart, ns));
|
||||||
if (!a.allDay && b.allDay) return 1;
|
const colEnd = Math.min(6, daysBetween(rowStart, ne));
|
||||||
return new Date(a.start) - new Date(b.start);
|
if (colEnd < colStart) return;
|
||||||
|
rowItems.push({
|
||||||
|
ev,
|
||||||
|
colStart,
|
||||||
|
span: colEnd - colStart + 1,
|
||||||
|
continuesLeft: ns < rowStart,
|
||||||
|
continuesRight: ne > rowEnd,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const isOther = cell.getMonth() !== month;
|
// Sort: all-day first, then span desc, then start time
|
||||||
const todayClass = isToday(cell) ? 'today' : '';
|
rowItems.sort((a, b) => {
|
||||||
const otherClass = isOther ? 'other-month' : '';
|
if (a.ev.allDay && !b.ev.allDay) return -1;
|
||||||
const numClass = isToday(cell) ? 'today' : '';
|
if (!a.ev.allDay && b.ev.allDay) return 1;
|
||||||
|
if (b.span !== a.span) return b.span - a.span;
|
||||||
|
return new Date(a.ev.start) - new Date(b.ev.start);
|
||||||
|
});
|
||||||
|
|
||||||
const MAX_VISIBLE = 3;
|
// Assign lanes (greedy interval packing)
|
||||||
const visible = cellEvs.slice(0, MAX_VISIBLE);
|
const lanes = [];
|
||||||
const hiddenCount = cellEvs.length - MAX_VISIBLE;
|
rowItems.forEach(item => {
|
||||||
|
let laneIdx = lanes.findIndex(l => item.colStart >= l.colEnd);
|
||||||
|
if (laneIdx === -1) { laneIdx = lanes.length; lanes.push({ colEnd: 0 }); }
|
||||||
|
item.lane = laneIdx;
|
||||||
|
lanes[laneIdx].colEnd = item.colStart + item.span;
|
||||||
|
});
|
||||||
|
|
||||||
const evHtml = visible.map(ev => {
|
// Track overflow per column
|
||||||
const color = ev.color || ev.calendarColor || '#4285f4';
|
const overflowByCol = {};
|
||||||
const pastClass = isPast(ev) ? 'past' : '';
|
rowItems.forEach(item => {
|
||||||
const title = ev.allDay ? ev.title : `${fmtTime(new Date(ev.start))} ${ev.title}`;
|
if (item.lane >= MAX_LANES) {
|
||||||
return `<div class="month-event ${pastClass}" data-id="${ev.id}" data-url="${escAttr(ev.url)}"
|
for (let c = item.colStart; c < item.colStart + item.span; c++) {
|
||||||
style="background:${color};color:#fff"
|
overflowByCol[c] = (overflowByCol[c] || 0) + 1;
|
||||||
title="${escAttr(ev.title)}">${escHtml(title)}</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
const moreHtml = hiddenCount > 0
|
|
||||||
? `<div class="month-more" data-date="${key}">${t('more_events', {n: hiddenCount})}</div>`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
cellsHtml += `<div class="month-cell ${todayClass} ${otherClass}" data-date="${key}">
|
|
||||||
<div class="cell-day ${numClass}">${cell.getDate()}</div>
|
|
||||||
${evHtml}${moreHtml}
|
|
||||||
</div>`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Render event spans HTML (placed in overlay)
|
||||||
|
let eventsHtml = '';
|
||||||
|
rowItems.forEach(item => {
|
||||||
|
if (item.lane >= MAX_LANES) return;
|
||||||
|
const { ev, colStart, span, continuesLeft, continuesRight } = item;
|
||||||
|
const leftPct = (colStart / 7) * 100;
|
||||||
|
const widthPct = (span / 7) * 100 - 0.4;
|
||||||
|
const topPx = item.lane * LANE_H + 2;
|
||||||
|
const color = ev.color || ev.calendarColor || '#4285f4';
|
||||||
|
const pastCls = isPast(ev) ? 'past' : '';
|
||||||
|
const cL = continuesLeft ? 'continues-left' : '';
|
||||||
|
const cR = continuesRight ? 'continues-right' : '';
|
||||||
|
const titleEsc = escHtml(ev.title);
|
||||||
|
const labelHtml = ev.allDay
|
||||||
|
? titleEsc
|
||||||
|
: `<span class="month-event-time">${escHtml(fmtTime(new Date(ev.start)))}</span> ${titleEsc}`;
|
||||||
|
eventsHtml += `<div class="month-span-event ${pastCls} ${cL} ${cR}"
|
||||||
|
data-id="${ev.id}" data-url="${escAttr(ev.url)}"
|
||||||
|
style="left:${leftPct.toFixed(3)}%;width:${widthPct.toFixed(3)}%;top:${topPx}px;background:${color}"
|
||||||
|
title="${escAttr(ev.title)}">${labelHtml}</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// "+N more" per column
|
||||||
|
Object.entries(overflowByCol).forEach(([col, count]) => {
|
||||||
|
const c = parseInt(col);
|
||||||
|
eventsHtml += `<div class="month-more"
|
||||||
|
data-date="${dateKey(rowCells[c])}"
|
||||||
|
style="left:${((c / 7) * 100).toFixed(3)}%;width:${(100 / 7).toFixed(3)}%;bottom:2px">${t('more_events', { n: count })}</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Detect a month boundary in this row. monthChangeIdx is the index of
|
||||||
|
// the first-of-month cell, or -1 if the row doesn't span a month change.
|
||||||
|
let monthChangeIdx = -1;
|
||||||
|
rowCells.forEach((cell, idx) => {
|
||||||
|
if (cell.getDate() === 1 && idx > 0) monthChangeIdx = idx;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Full-height column divs (click targets + borders)
|
||||||
|
const monthsShort = t('months_short');
|
||||||
|
let colsHtml = '';
|
||||||
|
rowCells.forEach((cell, idx) => {
|
||||||
|
const key = dateKey(cell);
|
||||||
|
const isOther = cell.getMonth() !== primaryMonth;
|
||||||
|
const todayCls = isToday(cell) ? 'today' : '';
|
||||||
|
const otherCls = isOther ? 'other-month' : '';
|
||||||
|
const selDate = selectedDate || currentDate;
|
||||||
|
const selectedCls = isSameDay(cell, selDate) ? 'month-selected' : '';
|
||||||
|
const numCls = isToday(cell) ? 'today' : '';
|
||||||
|
// First-of-month marker: show month abbreviation, push day number below
|
||||||
|
const isFirstOfMonth = cell.getDate() === 1;
|
||||||
|
const firstCls = isFirstOfMonth ? 'first-of-month' : '';
|
||||||
|
// Step-shaped boundary at month change: bottom under the previous-month
|
||||||
|
// tail, vertical at the change, top across the new-month head.
|
||||||
|
const dividerClasses = [];
|
||||||
|
if (isFirstOfMonth && idx > 0) dividerClasses.push('month-divider-left');
|
||||||
|
if (monthChangeIdx > 0 && idx >= monthChangeIdx) dividerClasses.push('month-divider-top');
|
||||||
|
if (monthChangeIdx > 0 && idx < monthChangeIdx) dividerClasses.push('month-divider-bottom');
|
||||||
|
const dividerCls = dividerClasses.join(' ');
|
||||||
|
const monthLabel = isFirstOfMonth
|
||||||
|
? `<div class="month-marker">${monthsShort[cell.getMonth()]}</div>`
|
||||||
|
: '';
|
||||||
|
colsHtml += `<div class="month-col ${todayCls} ${otherCls} ${selectedCls} ${firstCls} ${dividerCls}" data-date="${key}">
|
||||||
|
${monthLabel}
|
||||||
|
<div class="cell-day ${numCls}">${cell.getDate()}</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// If the row starts on the 1st of a new month, draw a full-width divider above the row
|
||||||
|
const rowDividerCls = rowCells[0].getDate() === 1 ? 'month-divider-top' : '';
|
||||||
|
// If any cell in the row is first-of-month, push events overlay down so the day
|
||||||
|
// number isn't hidden by spanning event bars
|
||||||
|
const hasMonthMarker = rowCells.some(c => c.getDate() === 1);
|
||||||
|
const rowMarkerCls = hasMonthMarker ? 'has-month-marker' : '';
|
||||||
|
bodyHtml += `<div class="month-row ${rowDividerCls} ${rowMarkerCls}">
|
||||||
|
<div class="month-kw-cell">${kw}</div>
|
||||||
|
<div class="month-row-right">
|
||||||
|
${colsHtml}
|
||||||
|
<div class="month-events-overlay">${eventsHtml}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
container.innerHTML = `<div class="month-view">
|
container.innerHTML = `<div class="month-view">
|
||||||
<div class="month-header">${headerHtml}</div>
|
<div class="month-header">${headerHtml}</div>
|
||||||
<div class="month-grid">${cellsHtml}</div>
|
<div class="month-body">${bodyHtml}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
// Events
|
// Click handlers via event delegation on the body
|
||||||
container.querySelectorAll('.month-cell').forEach(cell => {
|
const body = container.querySelector('.month-body');
|
||||||
cell.addEventListener('click', e => {
|
|
||||||
const evEl = e.target.closest('.month-event');
|
// Single click: select day (or handle event / more clicks)
|
||||||
if (evEl) {
|
body.addEventListener('click', e => {
|
||||||
|
// Span event click
|
||||||
|
const spanEl = e.target.closest('.month-span-event');
|
||||||
|
if (spanEl) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const ev = events.find(ev => ev.id === evEl.dataset.id && ev.url === evEl.dataset.url);
|
const ev = events.find(ev => ev.id === spanEl.dataset.id && ev.url === spanEl.dataset.url);
|
||||||
if (ev) onEventClick(ev, evEl);
|
if (ev) onEventClick(ev, spanEl);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// "+N more" → navigate to day view
|
||||||
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;
|
||||||
}
|
}
|
||||||
onDayClick(new Date(cell.dataset.date + 'T00:00:00'));
|
// Column click → select day
|
||||||
|
const colEl = e.target.closest('.month-col');
|
||||||
|
if (colEl) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function daysBetween(a, b) {
|
||||||
|
return Math.round((b - a) / 86400000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function dateKey(d) {
|
function dateKey(d) {
|
||||||
@@ -127,6 +239,7 @@ function fmtTime(d) {
|
|||||||
function escHtml(s) {
|
function escHtml(s) {
|
||||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
}
|
}
|
||||||
|
|
||||||
function escAttr(s) {
|
function escAttr(s) {
|
||||||
return String(s).replace(/"/g,'"').replace(/'/g,''');
|
return String(s).replace(/"/g,'"').replace(/'/g,''');
|
||||||
}
|
}
|
||||||
|
|||||||
112
frontend/js/views/quarter.js
Normal file
112
frontend/js/views/quarter.js
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { isToday, isPast, dayOfWeek } from '../utils.js';
|
||||||
|
import { t } from '../i18n.js';
|
||||||
|
|
||||||
|
export function renderQuarter(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') {
|
||||||
|
const year = currentDate.getFullYear();
|
||||||
|
// Quarter: Q1=0, Q2=1, Q3=2, Q4=3
|
||||||
|
const quarter = Math.floor(currentDate.getMonth() / 3);
|
||||||
|
const firstMonthOfQ = quarter * 3;
|
||||||
|
|
||||||
|
// Build event map keyed by date string
|
||||||
|
const evMap = {};
|
||||||
|
events.forEach(ev => {
|
||||||
|
const s = new Date(ev.start);
|
||||||
|
const e = new Date(ev.end);
|
||||||
|
const cur = new Date(s);
|
||||||
|
cur.setHours(0, 0, 0, 0);
|
||||||
|
const endNorm = new Date(e);
|
||||||
|
endNorm.setHours(0, 0, 0, 0);
|
||||||
|
if (ev.allDay && endNorm > cur) endNorm.setDate(endNorm.getDate() - 1);
|
||||||
|
while (cur <= endNorm) {
|
||||||
|
const key = dateKey(cur);
|
||||||
|
if (!evMap[key]) evMap[key] = [];
|
||||||
|
evMap[key].push(ev);
|
||||||
|
cur.setDate(cur.getDate() + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const DOW = weekStartDay === 'sunday' ? t('dow_sunday') : t('dow_monday');
|
||||||
|
const MONTHS = t('months');
|
||||||
|
|
||||||
|
const monthsHtml = [0, 1, 2].map(offset => {
|
||||||
|
const month = firstMonthOfQ + offset;
|
||||||
|
const firstDay = new Date(year, month, 1);
|
||||||
|
const lastDay = new Date(year, month + 1, 0);
|
||||||
|
|
||||||
|
// Start grid on correct weekday
|
||||||
|
const gridStart = new Date(firstDay);
|
||||||
|
const startOffset = dayOfWeek(firstDay, weekStartDay);
|
||||||
|
gridStart.setDate(gridStart.getDate() - startOffset);
|
||||||
|
|
||||||
|
const cells = [];
|
||||||
|
const d = new Date(gridStart);
|
||||||
|
for (let i = 0; i < 42; i++) {
|
||||||
|
cells.push(new Date(d));
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOW header
|
||||||
|
const dowHeader = DOW.map(d => `<div class="qtr-dow">${d}</div>`).join('');
|
||||||
|
|
||||||
|
// Rows
|
||||||
|
let rowsHtml = '';
|
||||||
|
for (let row = 0; row < 6; row++) {
|
||||||
|
for (let col = 0; col < 7; col++) {
|
||||||
|
const cell = cells[row * 7 + col];
|
||||||
|
const key = dateKey(cell);
|
||||||
|
const cellEvs = evMap[key] || [];
|
||||||
|
|
||||||
|
const isOther = cell.getMonth() !== month;
|
||||||
|
const todayCls = isToday(cell) ? 'today' : '';
|
||||||
|
const otherCls = isOther ? 'other-month' : '';
|
||||||
|
|
||||||
|
// Up to 3 event dots
|
||||||
|
const dots = cellEvs.slice(0, 3).map(ev => {
|
||||||
|
const color = ev.color || ev.calendarColor || '#4285f4';
|
||||||
|
const pastCls = isPast(ev) ? 'past' : '';
|
||||||
|
return `<span class="qtr-dot ${pastCls}" style="background:${color}" title="${escAttr(ev.title)}" data-id="${ev.id}" data-url="${escAttr(ev.url || '')}"></span>`;
|
||||||
|
}).join('');
|
||||||
|
const moreDot = cellEvs.length > 3
|
||||||
|
? `<span class="qtr-dot-more">+${cellEvs.length - 3}</span>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
rowsHtml += `<div class="qtr-cell ${todayCls} ${otherCls}" data-date="${key}">
|
||||||
|
<div class="qtr-day-num">${cell.getDate()}</div>
|
||||||
|
<div class="qtr-dots">${dots}${moreDot}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<div class="qtr-month">
|
||||||
|
<div class="qtr-month-name">${MONTHS[month]}</div>
|
||||||
|
<div class="qtr-month-grid">
|
||||||
|
<div class="qtr-header">${dowHeader}</div>
|
||||||
|
<div class="qtr-cells">${rowsHtml}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.innerHTML = `<div class="quarter-view">${monthsHtml}</div>`;
|
||||||
|
|
||||||
|
// Click handlers
|
||||||
|
container.querySelectorAll('.qtr-cell').forEach(cell => {
|
||||||
|
cell.addEventListener('click', e => {
|
||||||
|
// Check if a dot was clicked
|
||||||
|
const dot = e.target.closest('.qtr-dot');
|
||||||
|
if (dot) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const ev = events.find(ev => ev.id === dot.dataset.id && ev.url === dot.dataset.url);
|
||||||
|
if (ev) { onEventClick(ev, dot); return; }
|
||||||
|
}
|
||||||
|
onDayClick(new Date(cell.dataset.date + 'T00:00:00'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateKey(d) {
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escAttr(s) {
|
||||||
|
return String(s).replace(/"/g,'"').replace(/'/g,''');
|
||||||
|
}
|
||||||
@@ -18,6 +18,17 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
|
|||||||
// Separate all-day and timed events
|
// Separate all-day and timed events
|
||||||
const allDayEvs = events.filter(ev => ev.allDay);
|
const allDayEvs = events.filter(ev => ev.allDay);
|
||||||
const timedEvs = events.filter(ev => !ev.allDay);
|
const timedEvs = events.filter(ev => !ev.allDay);
|
||||||
|
// Multi-day timed events: timed but spanning more than one calendar day
|
||||||
|
const multiDayTimedEvs = timedEvs.filter(ev => !isSameDay(new Date(ev.start), new Date(ev.end)));
|
||||||
|
|
||||||
|
// Returns true if event overlaps any part of the given day
|
||||||
|
function spansDay(ev, day) {
|
||||||
|
const evStart = new Date(ev.start);
|
||||||
|
const evEnd = new Date(ev.end);
|
||||||
|
const dayStart = new Date(day); dayStart.setHours(0, 0, 0, 0);
|
||||||
|
const dayEnd = new Date(day); dayEnd.setHours(24, 0, 0, 0);
|
||||||
|
return evStart < dayEnd && evEnd > dayStart;
|
||||||
|
}
|
||||||
|
|
||||||
// ── KW Badge ──────────────────────────────────────────
|
// ── KW Badge ──────────────────────────────────────────
|
||||||
const kwNum = getISOWeekNumber(days[0]);
|
const kwNum = getISOWeekNumber(days[0]);
|
||||||
@@ -34,30 +45,50 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
|
|||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
// ── All-day row ───────────────────────────────────────
|
// ── All-day row (spanning bars, same logic as month view) ──
|
||||||
const alldayCols = days.map(day => {
|
const ALLDAY_LANE_H = 22;
|
||||||
const key = dayKey(day);
|
const allDayAndMulti = [...allDayEvs, ...multiDayTimedEvs];
|
||||||
const dayEvs = allDayEvs.filter(ev => {
|
const alldayLayout = layoutWeekAllDay(allDayAndMulti, days);
|
||||||
const s = new Date(ev.start); s.setHours(0,0,0,0);
|
// Items that span more than one column → used for column background tint
|
||||||
const e = new Date(ev.end); e.setHours(0,0,0,0);
|
const multiDayLayoutItems = alldayLayout.filter(item => item.colEnd > item.colStart);
|
||||||
const d = new Date(day); d.setHours(0,0,0,0);
|
const maxAlldayLane = alldayLayout.length ? alldayLayout.reduce((m, it) => Math.max(m, it.lane), 0) : -1;
|
||||||
return d >= s && d < e || isSameDay(d, s);
|
const alldayRowH = maxAlldayLane < 0 ? 28 : (maxAlldayLane + 1) * ALLDAY_LANE_H + 6;
|
||||||
});
|
|
||||||
const inner = dayEvs.map(ev => {
|
const alldaySpanHtml = alldayLayout.map(({ ev, colStart, colEnd, lane }) => {
|
||||||
|
const isMultiTimed = multiDayTimedEvs.includes(ev);
|
||||||
|
const n = days.length;
|
||||||
|
const left = (colStart / n) * 100;
|
||||||
|
const width = ((colEnd - colStart + 1) / n) * 100;
|
||||||
|
const top = lane * ALLDAY_LANE_H + 2;
|
||||||
const color = ev.color || ev.calendarColor || '#4285f4';
|
const color = ev.color || ev.calendarColor || '#4285f4';
|
||||||
return `<div class="allday-event" style="background:${color};color:#fff"
|
const pastCls = isPast(ev) ? 'past' : '';
|
||||||
data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(ev.title)}">${escHtml(ev.title)}</div>`;
|
const multiCls = isMultiTimed ? 'multiday-timed' : '';
|
||||||
}).join('');
|
const cL = new Date(ev.start) < new Date(days[0]) ? 'continues-left' : '';
|
||||||
return `<div class="allday-col" data-date="${key}">${inner}</div>`;
|
const cR = new Date(ev.end) > (() => { const d = new Date(days[n-1]); d.setHours(24,0,0,0); return d; })() ? 'continues-right' : '';
|
||||||
|
const label = isMultiTimed && isSameDay(new Date(ev.start), days[colStart])
|
||||||
|
? `${fmtTime(new Date(ev.start))} ${ev.title}`
|
||||||
|
: ev.title;
|
||||||
|
return `<div class="allday-span ${pastCls} ${multiCls} ${cL} ${cR}"
|
||||||
|
style="left:calc(${left.toFixed(2)}% + 1px);width:calc(${width.toFixed(2)}% - 2px);top:${top}px;background:${color};color:#fff"
|
||||||
|
data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(ev.title)}">${escHtml(label)}</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
const alldayBgCols = days.map(day =>
|
||||||
|
`<div class="allday-col-bg" data-date="${dayKey(day)}"></div>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
const alldayCols = `<div class="allday-cols-wrap" style="height:${alldayRowH}px">
|
||||||
|
${alldayBgCols}
|
||||||
|
<div class="allday-spans-layer">${alldaySpanHtml}</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
// ── Time column labels ────────────────────────────────
|
// ── Time column labels ────────────────────────────────
|
||||||
const timeLabels = Array.from({length: 24}, (_, h) =>
|
const timeLabels = Array.from({length: 24}, (_, h) =>
|
||||||
`<div class="time-label">${h === 0 ? '' : `${String(h).padStart(2,'0')}:00`}</div>`
|
`<div class="time-label">${h === 0 ? '' : `${String(h).padStart(2,'0')}:00`}</div>`
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
// ── Day columns ───────────────────────────────────────
|
// ── Day columns ───────────────────────────────────────
|
||||||
const dayCols = days.map(day => {
|
const dayCols = days.map((day, dayIdx) => {
|
||||||
const key = dayKey(day);
|
const key = dayKey(day);
|
||||||
const dayEvs = timedEvs.filter(ev => {
|
const dayEvs = timedEvs.filter(ev => {
|
||||||
const s = new Date(ev.start);
|
const s = new Date(ev.start);
|
||||||
@@ -74,7 +105,9 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
|
|||||||
const s = new Date(ev.start);
|
const s = new Date(ev.start);
|
||||||
const e = new Date(ev.end);
|
const e = new Date(ev.end);
|
||||||
const top = s.getHours() * hourH + s.getMinutes() * hourH / 60;
|
const top = s.getHours() * hourH + s.getMinutes() * hourH / 60;
|
||||||
const height = Math.max(20, (e - s) / 60000 * hourH / 60);
|
const dayEnd = new Date(s); dayEnd.setHours(24, 0, 0, 0);
|
||||||
|
const clampedEnd = e > dayEnd ? dayEnd : e;
|
||||||
|
const height = Math.max(20, (clampedEnd - s) / 60000 * hourH / 60);
|
||||||
const left = (col / cols) * 100;
|
const left = (col / cols) * 100;
|
||||||
const width = (1 / cols) * 100 - 0.5;
|
const width = (1 / cols) * 100 - 0.5;
|
||||||
const color = ev.color || ev.calendarColor || '#4285f4';
|
const color = ev.color || ev.calendarColor || '#4285f4';
|
||||||
@@ -90,7 +123,30 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
|
|||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
// Background tint: reuse alldayLayout (proven correct) — colEnd > colStart = multi-day
|
||||||
|
const dayTintEvs = multiDayLayoutItems
|
||||||
|
.filter(item => dayIdx >= item.colStart && dayIdx <= item.colEnd)
|
||||||
|
.map(item => item.ev);
|
||||||
|
const tintHtml = (() => {
|
||||||
|
if (!dayTintEvs.length) return '';
|
||||||
|
const colors = dayTintEvs.map(ev => ev.color || ev.calendarColor || '#4285f4');
|
||||||
|
let bg;
|
||||||
|
if (colors.length === 1) {
|
||||||
|
bg = colors[0] + '26';
|
||||||
|
} else {
|
||||||
|
// Vertical gradient bands for multiple overlapping multi-day events
|
||||||
|
const stops = colors.flatMap((c, i) => {
|
||||||
|
const p1 = ((i / colors.length) * 100).toFixed(1);
|
||||||
|
const p2 = (((i + 1) / colors.length) * 100).toFixed(1);
|
||||||
|
return [`${c}26 ${p1}%`, `${c}26 ${p2}%`];
|
||||||
|
}).join(',');
|
||||||
|
bg = `linear-gradient(to bottom,${stops})`;
|
||||||
|
}
|
||||||
|
return `<div class="col-span-tint" style="background:${bg}"></div>`;
|
||||||
|
})();
|
||||||
|
|
||||||
return `<div class="week-day-col" data-date="${key}" style="height:${hourH * 24}px">
|
return `<div class="week-day-col" data-date="${key}" style="height:${hourH * 24}px">
|
||||||
|
${tintHtml}
|
||||||
${hourLines}
|
${hourLines}
|
||||||
${evHtml}
|
${evHtml}
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -99,23 +155,25 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
|
|||||||
const viewClass = isSingleDay ? 'day-view' : 'week-view';
|
const viewClass = isSingleDay ? 'day-view' : 'week-view';
|
||||||
|
|
||||||
container.innerHTML = `<div class="${viewClass}">
|
container.innerHTML = `<div class="${viewClass}">
|
||||||
|
<div class="week-head-sticky">
|
||||||
<div class="week-header-row">
|
<div class="week-header-row">
|
||||||
<div class="week-time-gutter">${kwBadge}</div>
|
<div class="week-time-gutter">${kwBadge}</div>
|
||||||
${headerCols}
|
${headerCols}
|
||||||
</div>
|
</div>
|
||||||
<div class="week-allday-row">
|
<div class="week-allday-row">
|
||||||
<div class="allday-gutter">${t('allday')}</div>
|
<div class="allday-gutter">${t('allday')}</div>
|
||||||
<div class="allday-cols">${alldayCols}</div>
|
${alldayCols}
|
||||||
</div>
|
</div>
|
||||||
<div class="week-body">
|
</div>
|
||||||
|
<div class="week-time-area">
|
||||||
<div class="week-time-col">${timeLabels}</div>
|
<div class="week-time-col">${timeLabels}</div>
|
||||||
<div class="week-days-col">${dayCols}</div>
|
<div class="week-days-col">${dayCols}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
// Scroll to ~8:00
|
// Scroll to ~8:00
|
||||||
const body = container.querySelector('.week-body');
|
const scrollEl = container.querySelector(`.${viewClass}`);
|
||||||
if (body) body.scrollTop = 8 * hourH - 20;
|
if (scrollEl) scrollEl.scrollTop = 8 * hourH - 20;
|
||||||
|
|
||||||
// Render current-time line
|
// Render current-time line
|
||||||
renderNowLine(container, days, hourH);
|
renderNowLine(container, days, hourH);
|
||||||
@@ -125,7 +183,7 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
|
|||||||
col.addEventListener('click', e => {
|
col.addEventListener('click', e => {
|
||||||
if (e.target.closest('.timed-event')) return;
|
if (e.target.closest('.timed-event')) return;
|
||||||
const rect = col.getBoundingClientRect();
|
const rect = col.getBoundingClientRect();
|
||||||
const y = e.clientY - rect.top + (container.querySelector('.week-body')?.scrollTop || 0);
|
const y = e.clientY - rect.top + (scrollEl?.scrollTop || 0);
|
||||||
const h = Math.floor(y / hourH);
|
const h = Math.floor(y / hourH);
|
||||||
const m = Math.round(((y % hourH) / hourH * 60) / 15) * 15;
|
const m = Math.round(((y % hourH) / hourH * 60) / 15) * 15;
|
||||||
const date = new Date(col.dataset.date + 'T00:00:00');
|
const date = new Date(col.dataset.date + 'T00:00:00');
|
||||||
@@ -150,8 +208,8 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Click: all-day event
|
// Click: all-day span
|
||||||
container.querySelectorAll('.allday-event').forEach(el => {
|
container.querySelectorAll('.allday-span').forEach(el => {
|
||||||
el.addEventListener('click', e => {
|
el.addEventListener('click', e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const ev = events.find(ev => ev.id === el.dataset.id && ev.url === el.dataset.url);
|
const ev = events.find(ev => ev.id === el.dataset.id && ev.url === el.dataset.url);
|
||||||
@@ -175,6 +233,39 @@ function renderNowLine(container, days, hourH = 60) {
|
|||||||
setTimeout(() => renderNowLine(container, days, hourH), 60000);
|
setTimeout(() => renderNowLine(container, days, hourH), 60000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function layoutWeekAllDay(evs, days) {
|
||||||
|
const items = [];
|
||||||
|
evs.forEach(ev => {
|
||||||
|
let colStart = -1, colEnd = -1;
|
||||||
|
days.forEach((day, i) => {
|
||||||
|
const ds = new Date(day); ds.setHours(0, 0, 0, 0);
|
||||||
|
const de = new Date(day); de.setHours(24, 0, 0, 0);
|
||||||
|
if (new Date(ev.start) < de && new Date(ev.end) > ds) {
|
||||||
|
if (colStart === -1) colStart = i;
|
||||||
|
colEnd = i;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (colStart === -1) return;
|
||||||
|
items.push({ ev, colStart, colEnd });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort: longer spans first, then by start column
|
||||||
|
items.sort((a, b) =>
|
||||||
|
(b.colEnd - b.colStart) - (a.colEnd - a.colStart) || a.colStart - b.colStart
|
||||||
|
);
|
||||||
|
|
||||||
|
// Greedy lane assignment
|
||||||
|
const laneEnds = [];
|
||||||
|
items.forEach(item => {
|
||||||
|
let lane = laneEnds.findIndex(end => item.colStart > end);
|
||||||
|
if (lane === -1) { lane = laneEnds.length; laneEnds.push(-1); }
|
||||||
|
item.lane = lane;
|
||||||
|
laneEnds[lane] = item.colEnd;
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
function layoutEvents(events) {
|
function layoutEvents(events) {
|
||||||
if (!events.length) return [];
|
if (!events.length) return [];
|
||||||
|
|
||||||
|
|||||||
30
frontend/manifest.json
Normal file
30
frontend/manifest.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "Calendarr",
|
||||||
|
"short_name": "Calendarr",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
|
"background_color": "#0e0e14",
|
||||||
|
"theme_color": "#4285f4",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
109
frontend/sw.js
Normal file
109
frontend/sw.js
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
// Calendarr Service Worker
|
||||||
|
// Cache-first for static assets, network-first for /api/* (graceful offline)
|
||||||
|
|
||||||
|
const CACHE_VERSION = 'calendarr-v11';
|
||||||
|
const STATIC_ASSETS = [
|
||||||
|
'/',
|
||||||
|
'/index.html',
|
||||||
|
'/manifest.json',
|
||||||
|
'/static/css/app.css',
|
||||||
|
'/static/favicon.svg',
|
||||||
|
'/static/js/app.js',
|
||||||
|
'/static/js/api.js',
|
||||||
|
'/static/js/calendar.js',
|
||||||
|
'/static/js/color-picker.js',
|
||||||
|
'/static/js/date-picker.js',
|
||||||
|
'/static/js/i18n.js',
|
||||||
|
'/static/js/utils.js',
|
||||||
|
'/static/js/version.js',
|
||||||
|
'/static/js/views/agenda.js',
|
||||||
|
'/static/js/views/month.js',
|
||||||
|
'/static/js/views/quarter.js',
|
||||||
|
'/static/js/views/week.js',
|
||||||
|
'/icons/icon-192.png',
|
||||||
|
'/icons/icon-512.png',
|
||||||
|
'/icons/icon.svg',
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', event => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_VERSION).then(cache =>
|
||||||
|
// Use addAll with a fallback so a single missing file doesn't abort install
|
||||||
|
Promise.all(
|
||||||
|
STATIC_ASSETS.map(url =>
|
||||||
|
cache.add(url).catch(err => console.warn('[SW] skip', url, err))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).then(() => self.skipWaiting())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', event => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then(keys =>
|
||||||
|
Promise.all(keys.filter(k => k !== CACHE_VERSION).map(k => caches.delete(k)))
|
||||||
|
).then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', event => {
|
||||||
|
const req = event.request;
|
||||||
|
if (req.method !== 'GET') return;
|
||||||
|
|
||||||
|
const url = new URL(req.url);
|
||||||
|
|
||||||
|
// Network-first for API routes — fail silently if offline
|
||||||
|
if (url.pathname.startsWith('/api/')) {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(req).catch(() =>
|
||||||
|
new Response(JSON.stringify({ offline: true }), {
|
||||||
|
status: 503,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network-first for navigation (HTML) and the version-defining files —
|
||||||
|
// ensures users always get the freshest entry point so new releases
|
||||||
|
// take effect on the next reload without a manual SW unregister.
|
||||||
|
const isHtml = req.mode === 'navigate'
|
||||||
|
|| url.pathname === '/'
|
||||||
|
|| url.pathname === '/index.html';
|
||||||
|
const isVersionFile = url.pathname === '/static/js/version.js';
|
||||||
|
|
||||||
|
if (isHtml || isVersionFile) {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(req).then(resp => {
|
||||||
|
if (resp && resp.status === 200) {
|
||||||
|
const clone = resp.clone();
|
||||||
|
caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {});
|
||||||
|
}
|
||||||
|
return resp;
|
||||||
|
}).catch(() =>
|
||||||
|
caches.match(req).then(c => c || caches.match('/index.html'))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache-first for everything else (static)
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(req).then(cached => {
|
||||||
|
if (cached) return cached;
|
||||||
|
return fetch(req).then(resp => {
|
||||||
|
// Only cache successful, basic-origin responses
|
||||||
|
if (resp && resp.status === 200 && resp.type === 'basic') {
|
||||||
|
const clone = resp.clone();
|
||||||
|
caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {});
|
||||||
|
}
|
||||||
|
return resp;
|
||||||
|
}).catch(() => {
|
||||||
|
// Offline fallback for navigation requests
|
||||||
|
if (req.mode === 'navigate') return caches.match('/index.html');
|
||||||
|
return new Response('', { status: 503 });
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -10,3 +10,5 @@ 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
|
||||||
|
websocket-client==1.8.0
|
||||||
|
|||||||
Reference in New Issue
Block a user