fix: Runde-2-Fixes – Monatsauswahl, CalDAV-Update, Lösch-Dialog, EXDATE
- Monatsansicht: selectedDate von currentDate getrennt, Klick verschiebt View nicht mehr - Selected-Day Styling: weißer Text auf Primary-Hintergrund statt nur Textfarbe - Kontextmenü: --bg-surface statt fehlendem --bg-card - CalDAV Update/Delete: parent Calendar-Objekt übergeben (behebt NoneType-Fehler) - HA-Kalender im Kalender-Selektor ergänzt - Browser-confirm() durch styled Modal-Dialog ersetzt mit Serie/Einzeln-Option - EXDATE-Support: einzelne Vorkommen wiederkehrender Termine löschen (lokal + CalDAV) - Fehlende i18n-Keys für Lösch-Dialog ergänzt (DE + EN)
This commit is contained in:
@@ -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, vRecur
|
from icalendar import Calendar, Event, vDatetime, vRecur
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -213,10 +213,15 @@ 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 = caldav.Event(client=client, 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()
|
resource.load()
|
||||||
raw = resource.data
|
raw = resource.data
|
||||||
|
|
||||||
@@ -258,15 +263,31 @@ def update_event(
|
|||||||
elif "RRULE" in component:
|
elif "RRULE" in component:
|
||||||
del component["RRULE"]
|
del component["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)
|
||||||
|
|
||||||
resource.data = new_cal.to_ical().decode("utf-8")
|
resource.data = new_cal.to_ical().decode("utf-8")
|
||||||
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 = caldav.Event(client=client, 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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,13 @@ def _migrate():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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
|
||||||
|
|
||||||
_migrate()
|
_migrate()
|
||||||
|
|
||||||
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None)
|
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None)
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ class LocalEvent(Base):
|
|||||||
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)
|
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")
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ class EventUpdate(BaseModel):
|
|||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
color: Optional[str] = None
|
color: Optional[str] = None
|
||||||
rrule: 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:
|
||||||
@@ -84,6 +85,13 @@ def _account_dict(a: models.CalDAVAccount) -> dict:
|
|||||||
def _expand_recurring_local(ev, local_cal, range_start, range_end):
|
def _expand_recurring_local(ev, local_cal, range_start, range_end):
|
||||||
"""Expand a recurring LocalEvent into individual occurrences within the date range."""
|
"""Expand a recurring LocalEvent into individual occurrences within the date range."""
|
||||||
results = []
|
results = []
|
||||||
|
# Parse excluded dates
|
||||||
|
excluded = set()
|
||||||
|
if ev.exdate:
|
||||||
|
for d in ev.exdate.split(","):
|
||||||
|
d = d.strip()
|
||||||
|
if d:
|
||||||
|
excluded.add(d)
|
||||||
try:
|
try:
|
||||||
ev_start_str = ev.start.replace("Z", "+00:00")
|
ev_start_str = ev.start.replace("Z", "+00:00")
|
||||||
ev_end_str = ev.end.replace("Z", "+00:00")
|
ev_end_str = ev.end.replace("Z", "+00:00")
|
||||||
@@ -98,6 +106,9 @@ def _expand_recurring_local(ev, local_cal, range_start, range_end):
|
|||||||
occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
|
occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
|
||||||
for occ in occurrences:
|
for occ in occurrences:
|
||||||
occ_start = occ.date()
|
occ_start = occ.date()
|
||||||
|
occ_key = occ_start.strftime("%Y%m%d")
|
||||||
|
if occ_key in excluded:
|
||||||
|
continue
|
||||||
occ_end = occ_start + duration
|
occ_end = occ_start + duration
|
||||||
results.append({
|
results.append({
|
||||||
"id": ev.uid,
|
"id": ev.uid,
|
||||||
@@ -132,6 +143,9 @@ def _expand_recurring_local(ev, local_cal, range_start, range_end):
|
|||||||
r_end = r_end.replace(tzinfo=dt_timezone.utc)
|
r_end = r_end.replace(tzinfo=dt_timezone.utc)
|
||||||
occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
|
occurrences = rule.between(r_start - timedelta(days=1), r_end + timedelta(days=1), inc=True)
|
||||||
for occ in occurrences:
|
for occ in occurrences:
|
||||||
|
occ_key = occ.strftime("%Y%m%d")
|
||||||
|
if occ_key in excluded:
|
||||||
|
continue
|
||||||
occ_end = occ + duration
|
occ_end = occ + duration
|
||||||
results.append({
|
results.append({
|
||||||
"id": ev.uid,
|
"id": ev.uid,
|
||||||
@@ -548,6 +562,7 @@ def update_event(
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
account = None
|
account = None
|
||||||
|
cal_url = None
|
||||||
if calendar_id is not None:
|
if calendar_id is not None:
|
||||||
cal = (
|
cal = (
|
||||||
db.query(models.Calendar)
|
db.query(models.Calendar)
|
||||||
@@ -557,8 +572,15 @@ def update_event(
|
|||||||
)
|
)
|
||||||
if cal:
|
if cal:
|
||||||
account = next((a for a in accounts if a.id == cal.account_id), None)
|
account = next((a for a in accounts if a.id == cal.account_id), None)
|
||||||
|
cal_url = cal.cal_id
|
||||||
if not account:
|
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:
|
||||||
@@ -568,6 +590,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:
|
||||||
@@ -588,6 +611,7 @@ def delete_event(
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
account = None
|
account = None
|
||||||
|
cal_url = None
|
||||||
if calendar_id is not None:
|
if calendar_id is not None:
|
||||||
cal = (
|
cal = (
|
||||||
db.query(models.Calendar)
|
db.query(models.Calendar)
|
||||||
@@ -597,13 +621,20 @@ def delete_event(
|
|||||||
)
|
)
|
||||||
if cal:
|
if cal:
|
||||||
account = next((a for a in accounts if a.id == cal.account_id), None)
|
account = next((a for a in accounts if a.id == cal.account_id), None)
|
||||||
|
cal_url = cal.cal_id
|
||||||
if not account:
|
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:
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ class EventUpdate(BaseModel):
|
|||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
color: Optional[str] = None
|
color: Optional[str] = None
|
||||||
rrule: 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:
|
||||||
@@ -67,6 +68,7 @@ def _event_dict(ev: models.LocalEvent, cal: models.LocalCalendar) -> dict:
|
|||||||
"description": ev.description or "",
|
"description": ev.description or "",
|
||||||
"color": ev.color,
|
"color": ev.color,
|
||||||
"rrule": ev.rrule,
|
"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,
|
||||||
@@ -225,6 +227,12 @@ def update_event(
|
|||||||
ev.color = data.color
|
ev.color = data.color
|
||||||
if data.rrule is not None:
|
if data.rrule is not None:
|
||||||
ev.rrule = data.rrule if data.rrule else 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}
|
||||||
|
|
||||||
|
|||||||
@@ -487,7 +487,7 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
.month-col:hover { background: var(--bg-hover); }
|
.month-col:hover { background: var(--bg-hover); }
|
||||||
.month-col.today { background: rgba(66,133,244,.08); }
|
.month-col.today { background: rgba(66,133,244,.08); }
|
||||||
.month-col.month-selected { background: var(--primary-dim); }
|
.month-col.month-selected { background: var(--primary-dim); }
|
||||||
.month-col.month-selected .cell-day { color: var(--primary); font-weight: 600; }
|
.month-col.month-selected .cell-day { background: var(--primary); color: #fff; font-weight: 700; }
|
||||||
.month-col.other-month .cell-day { color: var(--text-3); }
|
.month-col.other-month .cell-day { color: var(--text-3); }
|
||||||
.cell-day {
|
.cell-day {
|
||||||
font-size: 12px; font-weight: 500; color: var(--text-2);
|
font-size: 12px; font-weight: 500; color: var(--text-2);
|
||||||
@@ -802,8 +802,8 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
/* ── Day Context Menu ──────────────────────────────────── */
|
/* ── Day Context Menu ──────────────────────────────────── */
|
||||||
.cal-context-menu {
|
.cal-context-menu {
|
||||||
position: fixed; z-index: 1000;
|
position: fixed; z-index: 1000;
|
||||||
background: var(--bg-card); border: 1px solid var(--border);
|
background: var(--bg-surface); border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm); box-shadow: 0 4px 16px rgba(0,0,0,.3);
|
border-radius: var(--radius-sm); box-shadow: 0 4px 16px rgba(0,0,0,.5);
|
||||||
min-width: 180px; padding: 4px 0;
|
min-width: 180px; padding: 4px 0;
|
||||||
}
|
}
|
||||||
.ctx-item {
|
.ctx-item {
|
||||||
|
|||||||
@@ -317,6 +317,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">
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ let weekStartDay = 'monday';
|
|||||||
|
|
||||||
let state = {
|
let state = {
|
||||||
currentDate: new Date(),
|
currentDate: new Date(),
|
||||||
|
selectedDate: null, // separate from currentDate; used for month-view selection
|
||||||
currentView: 'month',
|
currentView: 'month',
|
||||||
events: [],
|
events: [],
|
||||||
accounts: [],
|
accounts: [],
|
||||||
@@ -253,22 +254,23 @@ function renderView() {
|
|||||||
(date, action, mouseEvent) => {
|
(date, action, mouseEvent) => {
|
||||||
if (action === 'navigate') {
|
if (action === 'navigate') {
|
||||||
state.currentDate = date;
|
state.currentDate = date;
|
||||||
|
state.selectedDate = date;
|
||||||
state.currentView = 'day';
|
state.currentView = 'day';
|
||||||
updateViewButtons();
|
updateViewButtons();
|
||||||
fetchAndRender();
|
fetchAndRender();
|
||||||
} else if (action === 'context') {
|
} else if (action === 'context') {
|
||||||
state.currentDate = date;
|
state.selectedDate = date;
|
||||||
showDayContextMenu(date, mouseEvent);
|
showDayContextMenu(date, mouseEvent);
|
||||||
} else {
|
|
||||||
// 'select' — highlight day without navigating
|
|
||||||
state.currentDate = date;
|
|
||||||
renderMiniCal();
|
|
||||||
renderView();
|
renderView();
|
||||||
updateTitle();
|
} else {
|
||||||
|
// 'select' — only update selectedDate, don't shift the view
|
||||||
|
state.selectedDate = date;
|
||||||
|
renderView();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
showEventPopup,
|
showEventPopup,
|
||||||
weekStartDay
|
weekStartDay,
|
||||||
|
state.selectedDate
|
||||||
);
|
);
|
||||||
} else if (state.currentView === 'week') {
|
} else if (state.currentView === 'week') {
|
||||||
renderWeek(container, state.currentDate, evs,
|
renderWeek(container, state.currentDate, evs,
|
||||||
@@ -780,7 +782,7 @@ function bindTopbar() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('btn-settings').onclick = openSettingsModal;
|
document.getElementById('btn-settings').onclick = openSettingsModal;
|
||||||
document.getElementById('btn-create-event').onclick = () => openNewEventModal(state.currentDate);
|
document.getElementById('btn-create-event').onclick = () => openNewEventModal(state.selectedDate || state.currentDate);
|
||||||
|
|
||||||
// Mouse wheel / trackpad scroll navigation – only for month & quarter
|
// Mouse wheel / trackpad scroll navigation – only for month & quarter
|
||||||
let _wheelLast = 0;
|
let _wheelLast = 0;
|
||||||
@@ -854,6 +856,82 @@ function bindSidebar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Day Context Menu (month view) ────────────────────────
|
// ── Day Context Menu (month view) ────────────────────────
|
||||||
|
// ── Delete logic ──────────────────────────────────────────
|
||||||
|
async function deleteEventByScope(ev, scope) {
|
||||||
|
if (scope === 'all' || !ev.rrule) {
|
||||||
|
// Delete the entire event (or non-recurring)
|
||||||
|
if (ev.source === 'google') {
|
||||||
|
const accId = ev.calendar_id.replace('google-', '');
|
||||||
|
await api.delete(`/google/events/${accId}/${encodeURIComponent(ev.id)}`);
|
||||||
|
} else if (ev.source === 'local') {
|
||||||
|
await api.delete(`/local/events/${encodeURIComponent(ev.id)}`);
|
||||||
|
} else if (ev.source === 'ical') {
|
||||||
|
const subId = ev.calendar_id.replace('ical-', '');
|
||||||
|
await api.delete(`/ical/events/${subId}/${encodeURIComponent(ev.id)}`);
|
||||||
|
} else {
|
||||||
|
await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}&calendar_id=${ev.calendar_id}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Delete single occurrence: add EXDATE to exclude this date
|
||||||
|
const exdate = ev.start.slice(0, 10).replace(/-/g, '');
|
||||||
|
if (ev.source === 'local') {
|
||||||
|
// For local events: update rrule with EXDATE via a special field
|
||||||
|
const currentRrule = ev.rrule || '';
|
||||||
|
await api.put(`/local/events/${encodeURIComponent(ev.id)}`, {
|
||||||
|
exdate: exdate,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// For CalDAV: pass exdate to update
|
||||||
|
await api.put(
|
||||||
|
`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}&calendar_id=${ev.calendar_id}`,
|
||||||
|
{ exdate: exdate }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete Confirm Dialog ─────────────────────────────────
|
||||||
|
function showDeleteConfirm(ev) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const modal = document.getElementById('modal-delete-confirm');
|
||||||
|
const isRecurring = !!(ev.rrule);
|
||||||
|
|
||||||
|
document.getElementById('delete-confirm-title').textContent = t('confirm_delete_title');
|
||||||
|
document.getElementById('delete-confirm-text').textContent = t('confirm_delete_event', { title: ev.title });
|
||||||
|
document.getElementById('delete-series-options').classList.toggle('hidden', !isRecurring);
|
||||||
|
|
||||||
|
// Reset radio
|
||||||
|
const radios = modal.querySelectorAll('input[name="delete-scope"]');
|
||||||
|
radios[0].checked = true;
|
||||||
|
|
||||||
|
// Labels
|
||||||
|
const labels = modal.querySelectorAll('#delete-series-options label');
|
||||||
|
if (labels[0]) labels[0].lastChild.textContent = ' ' + t('delete_single');
|
||||||
|
if (labels[1]) labels[1].lastChild.textContent = ' ' + t('delete_all_series');
|
||||||
|
|
||||||
|
openModal('modal-delete-confirm');
|
||||||
|
|
||||||
|
const okBtn = document.getElementById('delete-confirm-ok');
|
||||||
|
const cleanup = () => {
|
||||||
|
okBtn.onclick = null;
|
||||||
|
modal.querySelectorAll('[data-modal="modal-delete-confirm"]').forEach(b => b.onclick = null);
|
||||||
|
};
|
||||||
|
|
||||||
|
okBtn.onclick = () => {
|
||||||
|
const scope = isRecurring
|
||||||
|
? modal.querySelector('input[name="delete-scope"]:checked')?.value || 'single'
|
||||||
|
: 'single';
|
||||||
|
cleanup();
|
||||||
|
closeModal('modal-delete-confirm');
|
||||||
|
resolve(scope);
|
||||||
|
};
|
||||||
|
|
||||||
|
modal.querySelectorAll('[data-modal="modal-delete-confirm"]').forEach(b => {
|
||||||
|
b.onclick = () => { cleanup(); closeModal('modal-delete-confirm'); resolve(null); };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function showDayContextMenu(date, mouseEvent) {
|
function showDayContextMenu(date, mouseEvent) {
|
||||||
document.querySelectorAll('.cal-context-menu').forEach(m => m.remove());
|
document.querySelectorAll('.cal-context-menu').forEach(m => m.remove());
|
||||||
|
|
||||||
@@ -953,20 +1031,11 @@ function showEventPopup(ev, anchor) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
document.getElementById('popup-delete').onclick = async () => {
|
document.getElementById('popup-delete').onclick = async () => {
|
||||||
if (!confirm(t("confirm_delete_event", {title: ev.title}))) return;
|
const scope = await showDeleteConfirm(ev);
|
||||||
|
if (!scope) return;
|
||||||
popup.classList.add('hidden');
|
popup.classList.add('hidden');
|
||||||
try {
|
try {
|
||||||
if (ev.source === 'google') {
|
await deleteEventByScope(ev, scope);
|
||||||
const accId = ev.calendar_id.replace('google-', '');
|
|
||||||
await api.delete(`/google/events/${accId}/${encodeURIComponent(ev.id)}`);
|
|
||||||
} else if (ev.source === 'local') {
|
|
||||||
await api.delete(`/local/events/${encodeURIComponent(ev.id)}`);
|
|
||||||
} else if (ev.source === 'ical') {
|
|
||||||
const subId = ev.calendar_id.replace('ical-', '');
|
|
||||||
await api.delete(`/ical/events/${subId}/${encodeURIComponent(ev.id)}`);
|
|
||||||
} else {
|
|
||||||
await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}&calendar_id=${ev.calendar_id}`);
|
|
||||||
}
|
|
||||||
showToast(t('event_deleted'));
|
showToast(t('event_deleted'));
|
||||||
fetchAndRender(true);
|
fetchAndRender(true);
|
||||||
} catch (e) { showToast(e.message, true); }
|
} catch (e) { showToast(e.message, true); }
|
||||||
@@ -1015,6 +1084,16 @@ function populateCalendarSelect(selectedId) {
|
|||||||
sel.appendChild(opt);
|
sel.appendChild(opt);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
// Home Assistant calendars
|
||||||
|
state.haAccounts.forEach(acc => {
|
||||||
|
acc.calendars.filter(c => c.enabled).forEach(cal => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = `homeassistant-${cal.id}`;
|
||||||
|
opt.textContent = `${acc.name} / ${cal.name}`;
|
||||||
|
if (`homeassistant-${cal.id}` === selectedId) opt.selected = true;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Date field helpers ────────────────────────────────────
|
// ── Date field helpers ────────────────────────────────────
|
||||||
@@ -1315,6 +1394,7 @@ function bindEventModal() {
|
|||||||
const calVal = document.getElementById('ev-calendar').value;
|
const calVal = document.getElementById('ev-calendar').value;
|
||||||
const isLocal = calVal.startsWith('local-');
|
const isLocal = calVal.startsWith('local-');
|
||||||
const isGoogle = calVal.startsWith('google-');
|
const isGoogle = calVal.startsWith('google-');
|
||||||
|
const isHA = calVal.startsWith('homeassistant-');
|
||||||
const loc = document.getElementById('ev-location').value.trim();
|
const loc = document.getElementById('ev-location').value.trim();
|
||||||
const desc = document.getElementById('ev-description').value.trim();
|
const desc = document.getElementById('ev-description').value.trim();
|
||||||
const color = state.selectedEventColor;
|
const color = state.selectedEventColor;
|
||||||
@@ -1373,6 +1453,9 @@ function bindEventModal() {
|
|||||||
rrule: rrule || null,
|
rrule: rrule || null,
|
||||||
});
|
});
|
||||||
showToast(t('event_created'));
|
showToast(t('event_created'));
|
||||||
|
} else if (isHA) {
|
||||||
|
showToast(t('ha_create_not_supported'), true);
|
||||||
|
return;
|
||||||
} else {
|
} else {
|
||||||
const calId = parseInt(calVal);
|
const calId = parseInt(calVal);
|
||||||
await api.post('/caldav/events', {
|
await api.post('/caldav/events', {
|
||||||
@@ -1392,19 +1475,10 @@ function bindEventModal() {
|
|||||||
document.getElementById('ev-delete').onclick = async () => {
|
document.getElementById('ev-delete').onclick = async () => {
|
||||||
const ev = state.editingEvent;
|
const ev = state.editingEvent;
|
||||||
if (!ev) return;
|
if (!ev) return;
|
||||||
if (!confirm(t("confirm_delete_event", {title: ev.title}))) return;
|
const scope = await showDeleteConfirm(ev);
|
||||||
|
if (!scope) return;
|
||||||
try {
|
try {
|
||||||
if (ev.source === 'google') {
|
await deleteEventByScope(ev, scope);
|
||||||
const accId = ev.calendar_id.replace('google-', '');
|
|
||||||
await api.delete(`/google/events/${accId}/${encodeURIComponent(ev.id)}`);
|
|
||||||
} else if (ev.source === 'local') {
|
|
||||||
await api.delete(`/local/events/${encodeURIComponent(ev.id)}`);
|
|
||||||
} else if (ev.source === 'ical') {
|
|
||||||
const subId = ev.calendar_id.replace('ical-', '');
|
|
||||||
await api.delete(`/ical/events/${subId}/${encodeURIComponent(ev.id)}`);
|
|
||||||
} else {
|
|
||||||
await api.delete(`/caldav/events/${encodeURIComponent(ev.id)}?event_url=${encodeURIComponent(ev.url)}&calendar_id=${ev.calendar_id}`);
|
|
||||||
}
|
|
||||||
showToast(t('event_deleted'));
|
showToast(t('event_deleted'));
|
||||||
closeModal('modal-event');
|
closeModal('modal-event');
|
||||||
fetchAndRender(true);
|
fetchAndRender(true);
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ const translations = {
|
|||||||
error_end_before_start: 'Ende kann nicht vor dem Start liegen',
|
error_end_before_start: 'Ende kann nicht vor dem Start liegen',
|
||||||
ctx_create_event: 'Neuen Termin erstellen',
|
ctx_create_event: 'Neuen Termin erstellen',
|
||||||
event_readonly: 'Abonnierte Termine können nicht bearbeitet werden',
|
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_label: 'Wiederholung',
|
||||||
rec_none: 'Keine', rec_daily: 'Täglich', rec_weekly: 'Wöchentlich',
|
rec_none: 'Keine', rec_daily: 'Täglich', rec_weekly: 'Wöchentlich',
|
||||||
rec_monthly: 'Monatlich', rec_yearly: 'Jährlich', rec_custom: 'Benutzerdefiniert…',
|
rec_monthly: 'Monatlich', rec_yearly: 'Jährlich', rec_custom: 'Benutzerdefiniert…',
|
||||||
@@ -154,6 +155,9 @@ const translations = {
|
|||||||
copy_to_calendar: 'Kopieren nach…', event_copied: 'Termin kopiert',
|
copy_to_calendar: 'Kopieren nach…', event_copied: 'Termin kopiert',
|
||||||
event_updated: 'Termin aktualisiert', event_created: 'Termin erstellt',
|
event_updated: 'Termin aktualisiert', event_created: 'Termin erstellt',
|
||||||
confirm_delete_event: '"{title}" wirklich löschen?',
|
confirm_delete_event: '"{title}" wirklich löschen?',
|
||||||
|
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',
|
||||||
@@ -349,6 +353,7 @@ const translations = {
|
|||||||
error_end_before_start: 'End cannot be before start',
|
error_end_before_start: 'End cannot be before start',
|
||||||
ctx_create_event: 'Create new event',
|
ctx_create_event: 'Create new event',
|
||||||
event_readonly: 'Subscribed events cannot be edited',
|
event_readonly: 'Subscribed events cannot be edited',
|
||||||
|
ha_create_not_supported: 'Events cannot be created directly in Home Assistant calendars',
|
||||||
rec_label: 'Recurrence',
|
rec_label: 'Recurrence',
|
||||||
rec_none: 'None', rec_daily: 'Daily', rec_weekly: 'Weekly',
|
rec_none: 'None', rec_daily: 'Daily', rec_weekly: 'Weekly',
|
||||||
rec_monthly: 'Monthly', rec_yearly: 'Yearly', rec_custom: 'Custom…',
|
rec_monthly: 'Monthly', rec_yearly: 'Yearly', rec_custom: 'Custom…',
|
||||||
@@ -358,6 +363,9 @@ const translations = {
|
|||||||
copy_to_calendar: 'Copy to…', event_copied: 'Event copied',
|
copy_to_calendar: 'Copy to…', event_copied: 'Event copied',
|
||||||
event_updated: 'Event updated', event_created: 'Event created',
|
event_updated: 'Event updated', event_created: 'Event created',
|
||||||
confirm_delete_event: 'Really delete "{title}"?',
|
confirm_delete_event: 'Really delete "{title}"?',
|
||||||
|
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',
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const LANE_H = 20; // px per lane (event height 18px + 2px gap)
|
|||||||
const DAY_H = 30; // day-number row height
|
const DAY_H = 30; // day-number row height
|
||||||
const NUM_ROWS = 5; // rolling view: always 5 weeks
|
const NUM_ROWS = 5; // rolling view: always 5 weeks
|
||||||
|
|
||||||
export function renderMonth(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') {
|
export function renderMonth(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday', selectedDate = null) {
|
||||||
// Dynamic lane limit: how many events fit in the actual row height
|
// Dynamic lane limit: how many events fit in the actual row height
|
||||||
const containerH = container.clientHeight || 600;
|
const containerH = container.clientHeight || 600;
|
||||||
const headerH = 34; // month-header DOW row
|
const headerH = 34; // month-header DOW row
|
||||||
@@ -126,7 +126,8 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
|
|||||||
const isOther = cell.getMonth() !== primaryMonth;
|
const isOther = cell.getMonth() !== primaryMonth;
|
||||||
const todayCls = isToday(cell) ? 'today' : '';
|
const todayCls = isToday(cell) ? 'today' : '';
|
||||||
const otherCls = isOther ? 'other-month' : '';
|
const otherCls = isOther ? 'other-month' : '';
|
||||||
const selectedCls = isSameDay(cell, currentDate) ? 'month-selected' : '';
|
const selDate = selectedDate || currentDate;
|
||||||
|
const selectedCls = isSameDay(cell, selDate) ? 'month-selected' : '';
|
||||||
const numCls = isToday(cell) ? 'today' : '';
|
const numCls = isToday(cell) ? 'today' : '';
|
||||||
colsHtml += `<div class="month-col ${todayCls} ${otherCls} ${selectedCls}" data-date="${key}">
|
colsHtml += `<div class="month-col ${todayCls} ${otherCls} ${selectedCls}" data-date="${key}">
|
||||||
<div class="cell-day ${numCls}">${cell.getDate()}</div>
|
<div class="cell-day ${numCls}">${cell.getDate()}</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user