diff --git a/backend/routers/local_router.py b/backend/routers/local_router.py index 8454fd5..3feff58 100644 --- a/backend/routers/local_router.py +++ b/backend/routers/local_router.py @@ -15,6 +15,7 @@ import permissions from auth import get_current_user from database import get_db from local_events_util import build_local_event_dict, resolve_creator +from urllib.parse import quote router = APIRouter() @@ -839,9 +840,20 @@ def export_calendar( atts = attachments_store.by_event(db, [e.id for e in events]) ics = ical_io.build_ics(cal, events, name_cache=name_cache, attachments_by_event=atts, base_url=base_url) - safe_name = "".join(c for c in cal.name if c.isalnum() or c in (" ", "-", "_")).strip() or "calendar" + # RFC 6266, the same way attachments_router already does it: str.isalnum() + # is true for "ö", so without an explicit ASCII test the "safe" name carries + # the very bytes it exists to avoid — a raw umlaut in an HTTP header, which + # a reverse proxy rejects outright. That is what made exporting a calendar + # called "Persönlich" fail with no usable error. + ascii_name = "".join( + c for c in cal.name if c.isascii() and (c.isalnum() or c in " -_") + ).strip() or "calendar" + disposition = ( + f'attachment; filename="{ascii_name}.ics"; ' + f"filename*=UTF-8''{quote(cal.name + '.ics')}" + ) return Response( content=ics, media_type="text/calendar", - headers={"Content-Disposition": f'attachment; filename="{safe_name}.ics"'}, + headers={"Content-Disposition": disposition}, ) diff --git a/frontend/js/api.js b/frontend/js/api.js index a080b42..8061469 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -86,8 +86,13 @@ async function downloadRequest(path, fallbackName) { return null; } if (!res.ok) { - const err = await res.json().catch(() => ({ detail: t('unknown_error') })); - const e = new Error(err.detail || `HTTP ${res.status}`); + // A non-JSON body means the failure happened outside the app — a reverse + // proxy rejecting the response, say. Showing "unknown error" there hides + // the one clue there is, so fall back to the body text and the status. + const raw = await res.text().catch(() => ''); + let detail = ''; + try { detail = JSON.parse(raw).detail || ''; } catch { detail = raw.trim().slice(0, 200); } + const e = new Error(detail || t('unknown_error') + ' (HTTP ' + res.status + ')'); // Keep the status so callers can map it to a translated message instead of // showing the backend's German detail to someone running the app in Finnish. e.status = res.status; diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index bb048d4..3175f91 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -4383,7 +4383,7 @@ function initResizableCalTable(table) { // Lock to px + fixed layout so dragging one column doesn't reflow the others. // Sensible defaults per column (Name / Herkunft / Sichtbar / Benachrichtigungen // / Export-Import / Sync / trash); user drags override and persist. - const DEFAULTS = [200, 90, 80, 150, 240, 90, 44]; + const DEFAULTS = [200, 90, 80, 150, 300, 90, 44]; ths.forEach((th, i) => { const w = (Array.isArray(saved) && saved[i]) ? saved[i] : (DEFAULTS[i] || th.offsetWidth || 100); @@ -4458,7 +4458,7 @@ function renderCalendarTable() { - + —