Make exporting a calendar work, and CalDAV publishing findable
Exporting any calendar whose name is not pure ASCII failed. str.isalnum() is true for "ö", so the "safe" filename kept the umlaut and went into Content-Disposition raw — an HTTP header must be ASCII, and a reverse proxy drops the response. The client then had no JSON to read and showed its "unknown error" fallback, which said nothing at all. attachments_router had already learned this and documents it in a comment; the export endpoint never got the same treatment. Now RFC 6266, exactly as that router does it. downloadRequest discarded a non-JSON error body entirely. That body is the only clue when the failure happens outside the app, so it is used now, with the status appended when there is nothing to read. The CalDAV publish button rendered as "..." — four buttons never fitted the 240px actions column, and the cell clips instead of overflowing because the columns are drag-resizable. Nobody could tell there was a button there, which is why publishing a calendar to Home Assistant looked impossible: the URL, the copy button and both hints only appear once it is switched on. The label is now just "CalDAV" with the full action as its tooltip, which also helps anyone who already dragged that column narrower, since saved widths beat the default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
93297319a3
commit
a153eea8f9
@@ -15,6 +15,7 @@ import permissions
|
|||||||
from auth import get_current_user
|
from auth import get_current_user
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from local_events_util import build_local_event_dict, resolve_creator
|
from local_events_util import build_local_event_dict, resolve_creator
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -839,9 +840,20 @@ def export_calendar(
|
|||||||
atts = attachments_store.by_event(db, [e.id for e in events])
|
atts = attachments_store.by_event(db, [e.id for e in events])
|
||||||
ics = ical_io.build_ics(cal, events, name_cache=name_cache,
|
ics = ical_io.build_ics(cal, events, name_cache=name_cache,
|
||||||
attachments_by_event=atts, base_url=base_url)
|
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(
|
return Response(
|
||||||
content=ics,
|
content=ics,
|
||||||
media_type="text/calendar",
|
media_type="text/calendar",
|
||||||
headers={"Content-Disposition": f'attachment; filename="{safe_name}.ics"'},
|
headers={"Content-Disposition": disposition},
|
||||||
)
|
)
|
||||||
|
|||||||
+7
-2
@@ -86,8 +86,13 @@ async function downloadRequest(path, fallbackName) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ detail: t('unknown_error') }));
|
// A non-JSON body means the failure happened outside the app — a reverse
|
||||||
const e = new Error(err.detail || `HTTP ${res.status}`);
|
// 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
|
// 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.
|
// showing the backend's German detail to someone running the app in Finnish.
|
||||||
e.status = res.status;
|
e.status = res.status;
|
||||||
|
|||||||
@@ -4383,7 +4383,7 @@ function initResizableCalTable(table) {
|
|||||||
// Lock to px + fixed layout so dragging one column doesn't reflow the others.
|
// Lock to px + fixed layout so dragging one column doesn't reflow the others.
|
||||||
// Sensible defaults per column (Name / Herkunft / Sichtbar / Benachrichtigungen
|
// Sensible defaults per column (Name / Herkunft / Sichtbar / Benachrichtigungen
|
||||||
// / Export-Import / Sync / trash); user drags override and persist.
|
// / 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) => {
|
ths.forEach((th, i) => {
|
||||||
const w = (Array.isArray(saved) && saved[i]) ? saved[i]
|
const w = (Array.isArray(saved) && saved[i]) ? saved[i]
|
||||||
: (DEFAULTS[i] || th.offsetWidth || 100);
|
: (DEFAULTS[i] || th.offsetWidth || 100);
|
||||||
@@ -4458,7 +4458,7 @@ function renderCalendarTable() {
|
|||||||
<button class="btn btn-ghost btn-sm" data-ct-share="${cal.id}">${t('share')}</button>
|
<button class="btn btn-ghost btn-sm" data-ct-share="${cal.id}">${t('share')}</button>
|
||||||
<button class="btn btn-ghost btn-sm" data-ct-export="local" data-ct-id="${cal.id}" data-ct-name="${escHtml(cal.name)}">Export</button>
|
<button class="btn btn-ghost btn-sm" data-ct-export="local" data-ct-id="${cal.id}" data-ct-name="${escHtml(cal.name)}">Export</button>
|
||||||
<button class="btn btn-ghost btn-sm" data-ct-import="local" data-ct-id="${cal.id}">Import</button>
|
<button class="btn btn-ghost btn-sm" data-ct-import="local" data-ct-id="${cal.id}">Import</button>
|
||||||
<button class="btn btn-ghost btn-sm ct-dav-toggle${pub ? ' on' : ''}" data-ct-dav-id="${cal.id}" data-ct-dav-on="${pub ? '1' : '0'}">${pub ? t('caldav_unpublish') : t('caldav_publish')}</button>
|
<button class="btn btn-ghost btn-sm ct-dav-toggle${pub ? ' on' : ''}" data-ct-dav-id="${cal.id}" data-ct-dav-on="${pub ? '1' : '0'}" title="${pub ? t('caldav_unpublish') : t('caldav_publish')}">CalDAV</button>
|
||||||
</td>
|
</td>
|
||||||
<td>—</td>
|
<td>—</td>
|
||||||
<td><button class="icon-btn mini-btn" data-ct-del="local" data-ct-id="${cal.id}">${TRASH}</button></td>
|
<td><button class="icon-btn mini-btn" data-ct-del="local" data-ct-id="${cal.id}">${TRASH}</button></td>
|
||||||
|
|||||||
Reference in New Issue
Block a user