diff --git a/backend/routers/attachments_router.py b/backend/routers/attachments_router.py
index cd0974c..72be2c4 100644
--- a/backend/routers/attachments_router.py
+++ b/backend/routers/attachments_router.py
@@ -87,8 +87,11 @@ def _download_response(att: models.EventAttachment) -> FileResponse:
# Row without bytes: the sweep will reconcile it on its next run.
raise HTTPException(404, "Attachment not found")
# RFC 6266: an ASCII fallback plus the real UTF-8 name, so umlauts survive.
+ # str.isalnum() is true for "ä" and friends, so the ASCII test is explicit —
+ # otherwise the "ASCII" fallback would carry the very bytes it exists to
+ # avoid, and older clients would garble the name.
ascii_name = "".join(
- c for c in att.filename if c.isalnum() or c in " -_."
+ c for c in att.filename if c.isascii() and (c.isalnum() or c in " -_.")
).strip() or "anhang"
disposition = (
f'attachment; filename="{ascii_name}"; '
diff --git a/backend/tests/test_attachments.py b/backend/tests/test_attachments.py
index ca9fce3..57427cf 100644
--- a/backend/tests/test_attachments.py
+++ b/backend/tests/test_attachments.py
@@ -645,3 +645,17 @@ def test_caldav_put_leaves_attachments_alone(client):
assert r.status_code in (201, 204), r.text
assert _row_count() == 1
assert len(_attach_props(client.get(dav_path).text)) == 1
+
+
+def test_download_filename_fallback_is_really_ascii(client):
+ """str.isalnum() is true for umlauts, so the ASCII fallback needs an
+ explicit ascii test — otherwise it carries the bytes it exists to avoid."""
+ token = register_admin(client)
+ ev = _make_event(client, token, _make_calendar(client, token))
+ att_id = _upload(client, token, ev["id"], name="Rechnung Zahnärztin.pdf").json()["id"]
+
+ dl = client.get(f"/api/local/attachments/{att_id}", headers=auth(token))
+ disposition = dl.headers["content-disposition"]
+ fallback = disposition.split('filename="', 1)[1].split('"', 1)[0]
+ assert fallback.isascii(), fallback
+ assert "filename*=UTF-8''Rechnung%20Zahn%C3%A4rztin.pdf" in disposition
diff --git a/frontend/css/app.css b/frontend/css/app.css
index 555b8dd..8d02fbe 100644
--- a/frontend/css/app.css
+++ b/frontend/css/app.css
@@ -397,6 +397,27 @@ a { color: var(--primary); text-decoration: none; }
.ev-reminder-custom { display: inline-flex; align-items: center; gap: 6px; flex-basis: 100%; }
.ev-reminder-custom input[type="number"] { width: 64px; }
.ev-reminder-before { color: var(--text-3); font-size: 12px; }
+
+/* ── Attachments ─────────────────────────────────────── */
+.ev-attach-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 6px; }
+.ev-attach-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px;
+ border: 1px solid var(--border); border-radius: 8px; }
+.ev-attach-row:hover { background: var(--hover-highlight); }
+.ev-attach-row.is-pending { background: rgba(66, 133, 244, .10); }
+.ev-attach-row.is-pending { background: color-mix(in srgb, var(--primary) 10%, transparent); }
+.ev-attach-thumb { width: 32px; height: 32px; object-fit: cover; border-radius: 4px;
+ flex-shrink: 0; background: var(--bg-app); }
+.ev-attach-icon { width: 32px; height: 32px; flex-shrink: 0; color: var(--text-3); }
+.ev-attach-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis;
+ white-space: nowrap; }
+.ev-attach-size { color: var(--text-3); font-size: 12px; flex-shrink: 0; }
+.ev-attach-remove { flex-shrink: 0; }
+.popup-attach-list { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
+.popup-attach-item { display: flex; align-items: center; gap: 6px; cursor: pointer;
+ min-width: 0; background: none; border: 0; padding: 0;
+ color: inherit; font: inherit; text-align: left; }
+.popup-attach-item:hover .ev-attach-name { color: var(--primary); }
+.popup-row-attach { align-items: flex-start; }
.form-hint { font-size: 12px; color: var(--text-3); margin-bottom: 6px; }
.reminders-disabled .ev-reminders-list,
.reminders-disabled #ev-reminder-add { opacity: 0.5; }
diff --git a/frontend/index.html b/frontend/index.html
index ec3203a..5c296c9 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -397,6 +397,12 @@
+
+
diff --git a/frontend/js/api.js b/frontend/js/api.js
index dcae3d1..a080b42 100644
--- a/frontend/js/api.js
+++ b/frontend/js/api.js
@@ -34,7 +34,11 @@ async function request(method, path, body = null, formEncoded = false) {
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: t('unknown_error') }));
- throw new Error(err.detail || `HTTP ${res.status}`);
+ const e = new Error(err.detail || `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;
+ throw e;
}
if (res.status === 204) return null;
@@ -61,7 +65,9 @@ async function uploadRequest(path, formData) {
const detail = (err && err.detail)
? err.detail
: (res.status === 413 ? t('upload_too_large') : `HTTP ${res.status} ${res.statusText || ''}`.trim());
- throw new Error(detail);
+ const e = new Error(detail);
+ e.status = res.status;
+ throw e;
}
if (res.status === 204) return null;
return res.json();
@@ -81,7 +87,11 @@ async function downloadRequest(path, fallbackName) {
}
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: t('unknown_error') }));
- throw new Error(err.detail || `HTTP ${res.status}`);
+ const e = new Error(err.detail || `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;
+ throw e;
}
// Derive filename from Content-Disposition if present.
let filename = fallbackName || 'calendar.ics';
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js
index 41f62bb..4d85b99 100644
--- a/frontend/js/calendar.js
+++ b/frontend/js/calendar.js
@@ -1625,9 +1625,12 @@ function showEventPopup(ev, anchor) {
document.getElementById('popup-row-creator').style.display = 'none';
}
- // Position near anchor
+ renderPopupAttachments(ev);
+
+ // Position near anchor. The height was hard-coded at 200, which an attachment
+ // list easily exceeds — measure instead, and fall back to the old guess.
const rect = anchor.getBoundingClientRect();
- const pw = 320, ph = 200;
+ const pw = 320, ph = popup.offsetHeight || 200;
let left = rect.right + 8;
let top = rect.top;
if (left + pw > window.innerWidth) left = rect.left - pw - 8;
@@ -1958,6 +1961,8 @@ function openNewEventModal(date) {
const defMin = state.settings && state.settings.default_reminder_minutes;
setEventReminders(defMin != null ? [defMin] : []);
updateRemindersRow();
+ resetAttachmentState();
+ updateAttachmentsRow();
resetColorPicker('');
resetRecurrenceUI();
document.getElementById('ev-delete').classList.add('hidden');
@@ -1996,6 +2001,8 @@ function openCopyEditModal(ev, targetCal) {
updatePrivateRow(ev.private);
setEventReminders(ev.reminders || []);
updateRemindersRow();
+ resetAttachmentState();
+ updateAttachmentsRow();
resetColorPicker(ev.color || '');
resetRecurrenceUI();
@@ -2031,6 +2038,8 @@ function openEditEventModal(ev) {
updatePrivateRow(ev.private);
setEventReminders(ev.reminders || []);
updateRemindersRow();
+ loadEventAttachments(ev);
+ updateAttachmentsRow();
resetColorPicker(ev.color || '');
// Recurrence
@@ -2219,6 +2228,298 @@ function updateRemindersRow() {
group.querySelectorAll('select, input, button').forEach(el => { el.disabled = disabled; });
}
+// ── Attachments ───────────────────────────────────────────
+// Attachments exist on local events only — the other sources are fetched live
+// from a remote server and have no row here to hang a file on. The editor
+// stages changes and applies them on Save, matching the modal's "nothing
+// happens until you save" contract; the popup fetches the list lazily, because
+// the event payload carries only attachment_count.
+
+const ATTACH_ACCEPT = 'application/pdf,image/png,image/jpeg,image/webp,image/gif,.txt,.md,.csv';
+const ATTACH_MAX_BYTES = 10 * 1024 * 1024;
+const ATTACH_MAX_COUNT = 10;
+const SVG_NS = 'http://www.w3.org/2000/svg';
+
+const ICON_PATH_DOC = 'M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm0 2l4 4h-4V4zM8 13h8v2H8v-2zm0 4h8v2H8v-2z';
+const ICON_PATH_PDF = 'M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm0 2l4 4h-4V4zM8 12h1.5a1.5 1.5 0 010 3H9v2H8v-5zm3.5 0H13a2 2 0 012 2v1a2 2 0 01-2 2h-1.5v-5z';
+
+function formatFileSize(bytes) {
+ const n = Number(bytes);
+ if (!isFinite(n)) return '';
+ if (n < 1024) return `${n} B`;
+ if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+// Map the HTTP status to a translated message. Without this the backend's
+// German detail would be shown to someone running the app in another language.
+function attachErrorMessage(e) {
+ if (e && e.status === 409) return t('attachment_limit_reached');
+ if (e && e.status === 413) return t('attachment_too_large');
+ if (e && e.status === 400) return t('attachment_type_not_allowed');
+ return t('attachment_upload_failed', { error: (e && e.message) || '' });
+}
+
+function attachIconEl(contentType) {
+ const svg = document.createElementNS(SVG_NS, 'svg');
+ svg.setAttribute('viewBox', '0 0 24 24');
+ svg.setAttribute('class', 'ev-attach-icon');
+ svg.setAttribute('fill', 'currentColor');
+ const path = document.createElementNS(SVG_NS, 'path');
+ path.setAttribute('d', contentType === 'application/pdf' ? ICON_PATH_PDF : ICON_PATH_DOC);
+ svg.appendChild(path);
+ return svg;
+}
+
+//
cannot carry the bearer token, so fetch the thumbnail and
+// hand the element a blob URL. Revoke it as soon as the image has decoded —
+// the browser keeps its own copy, and the row is rebuilt on every render.
+// (fetchAvatarBlob and loadAvatarImage never revoke; don't copy that.)
+async function setAttachmentThumb(imgEl, attId) {
+ try {
+ const token = localStorage.getItem('token');
+ const res = await fetch(`/api/local/attachments/${attId}/thumb`, {
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
+ });
+ if (!res.ok) return; // the type icon stays in place
+ const url = URL.createObjectURL(await res.blob());
+ imgEl.onload = () => URL.revokeObjectURL(url);
+ imgEl.onerror = () => URL.revokeObjectURL(url);
+ imgEl.src = url;
+ imgEl.style.display = '';
+ } catch (e) {
+ /* offline or blocked — the icon is a fine fallback */
+ }
+}
+
+// Filenames come from other users via calendar sharing, so they only ever go
+// into textContent, never into innerHTML.
+function attachmentRow({ name, size, contentType, thumbId, pending, onRemove }) {
+ const row = document.createElement('div');
+ row.className = 'ev-attach-row' + (pending ? ' is-pending' : '');
+
+ if (thumbId) {
+ const img = document.createElement('img');
+ img.className = 'ev-attach-thumb';
+ img.alt = '';
+ img.style.display = 'none';
+ row.appendChild(attachIconEl(contentType));
+ row.appendChild(img);
+ setAttachmentThumb(img, thumbId).then(() => {
+ if (img.getAttribute('src')) row.firstChild.remove();
+ });
+ } else {
+ row.appendChild(attachIconEl(contentType));
+ }
+
+ const label = document.createElement('span');
+ label.className = 'ev-attach-name';
+ label.textContent = name;
+ label.title = name;
+ row.appendChild(label);
+
+ const sizeEl = document.createElement('span');
+ sizeEl.className = 'ev-attach-size';
+ sizeEl.textContent = formatFileSize(size);
+ row.appendChild(sizeEl);
+
+ if (onRemove) {
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'icon-btn ev-attach-remove';
+ btn.textContent = '×';
+ btn.title = t('delete');
+ btn.addEventListener('click', onRemove);
+ row.appendChild(btn);
+ }
+ return row;
+}
+
+function attachmentStagedCount() {
+ const removed = state.removedAttachments || [];
+ const existing = (state.eventAttachments || []).filter(a => !removed.includes(a.id));
+ return existing.length + (state.pendingAttachments || []).length;
+}
+
+function resetAttachmentState() {
+ state.eventAttachments = [];
+ state.pendingAttachments = [];
+ state.removedAttachments = [];
+}
+
+function renderAttachmentRows() {
+ const list = document.getElementById('ev-attachments-list');
+ if (!list) return;
+ list.innerHTML = '';
+ const removed = state.removedAttachments || [];
+ const existing = (state.eventAttachments || []).filter(a => !removed.includes(a.id));
+ const pending = state.pendingAttachments || [];
+
+ if (!existing.length && !pending.length) {
+ const empty = document.createElement('div');
+ empty.className = 'form-hint';
+ empty.textContent = t('attachment_none');
+ list.appendChild(empty);
+ }
+ existing.forEach(a => list.appendChild(attachmentRow({
+ name: a.filename, size: a.size_bytes, contentType: a.content_type,
+ thumbId: a.has_thumb ? a.id : null,
+ onRemove: () => { state.removedAttachments.push(a.id); renderAttachmentRows(); },
+ })));
+ pending.forEach((f, i) => list.appendChild(attachmentRow({
+ name: f.name, size: f.size, contentType: f.type, pending: true,
+ onRemove: () => { state.pendingAttachments.splice(i, 1); renderAttachmentRows(); },
+ })));
+
+ const addBtn = document.getElementById('ev-attachment-add');
+ if (addBtn) addBtn.disabled = attachmentStagedCount() >= ATTACH_MAX_COUNT;
+}
+
+// Attachments apply to local calendars only — same gate as the private toggle.
+function updateAttachmentsRow() {
+ const calVal = document.getElementById('ev-calendar').value || '';
+ const isLocal = calVal.startsWith('local-');
+ const group = document.getElementById('ev-attachments-group');
+ if (!group) return;
+ group.style.display = isLocal ? '' : 'none';
+ if (!isLocal && (state.pendingAttachments || []).length) {
+ // Switching to a remote calendar drops staged files: there would be
+ // nowhere to put them. Say so rather than losing them silently.
+ state.pendingAttachments = [];
+ showToast(t('attachment_save_first'), true);
+ }
+ if (isLocal) renderAttachmentRows();
+}
+
+function triggerAttachmentPicker() {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.multiple = true;
+ input.accept = ATTACH_ACCEPT;
+ input.style.display = 'none';
+ document.body.appendChild(input);
+ input.addEventListener('change', () => {
+ const files = Array.from(input.files || []);
+ input.remove();
+ for (const f of files) {
+ if (attachmentStagedCount() >= ATTACH_MAX_COUNT) {
+ showToast(t('attachment_limit_reached'), true);
+ break;
+ }
+ // The server checks both again; this is only to fail fast.
+ if (f.size > ATTACH_MAX_BYTES) {
+ showToast(t('attachment_too_large'), true);
+ continue;
+ }
+ state.pendingAttachments.push(f);
+ }
+ renderAttachmentRows();
+ });
+ input.click();
+}
+
+async function loadEventAttachments(ev) {
+ resetAttachmentState();
+ if (ev && ev.source === 'local' && (ev.attachment_count || 0) > 0) {
+ try {
+ state.eventAttachments = await api.get(
+ `/local/events/${encodeURIComponent(ev.id)}/attachments`) || [];
+ } catch (e) {
+ /* the editor still opens; the list just stays empty */
+ }
+ }
+ renderAttachmentRows();
+}
+
+// Applied after the event itself was saved, because a new event has no uid to
+// attach to until then. If this fails the event still exists — say so plainly
+// instead of reporting success.
+async function flushAttachmentChanges(uid) {
+ const removed = state.removedAttachments || [];
+ const pending = state.pendingAttachments || [];
+ let failed = 0;
+ let lastError = null;
+
+ for (const id of removed) {
+ try {
+ await api.delete(`/local/attachments/${id}`);
+ } catch (e) { failed++; lastError = e; }
+ }
+ for (const f of pending) {
+ const form = new FormData();
+ form.append('file', f, f.name);
+ try {
+ await api.upload(`/local/events/${encodeURIComponent(uid)}/attachments`, form);
+ } catch (e) { failed++; lastError = e; }
+ }
+
+ const total = removed.length + pending.length;
+ resetAttachmentState();
+ if (failed === 1) {
+ showToast(attachErrorMessage(lastError), true);
+ } else if (failed > 1) {
+ showToast(t('attachment_upload_partial', { failed, total }), true);
+ }
+}
+
+// ── Attachments in the detail popup ───────────────────────
+
+async function renderPopupAttachments(ev) {
+ const row = document.getElementById('popup-row-attachments');
+ const list = document.getElementById('popup-attachments');
+ if (!row || !list) return;
+ list.innerHTML = '';
+ if (ev.source !== 'local' || !((ev.attachment_count || 0) > 0)) {
+ row.style.display = 'none';
+ return;
+ }
+ row.style.display = '';
+ const placeholder = document.createElement('span');
+ placeholder.className = 'ev-attach-size';
+ placeholder.textContent = '…';
+ list.appendChild(placeholder);
+
+ let items = [];
+ try {
+ items = await api.get(`/local/events/${encodeURIComponent(ev.id)}/attachments`) || [];
+ } catch (e) {
+ list.innerHTML = '';
+ const err = document.createElement('span');
+ err.className = 'ev-attach-size';
+ err.textContent = (e && e.message) || '';
+ list.appendChild(err);
+ return;
+ }
+ // The popup may have been closed or re-rendered while we waited.
+ if (document.getElementById('popup-attachments') !== list) return;
+
+ list.innerHTML = '';
+ if (!items.length) { row.style.display = 'none'; return; }
+ for (const a of items) {
+ const item = document.createElement('button');
+ item.type = 'button';
+ item.className = 'popup-attach-item';
+ item.appendChild(attachIconEl(a.content_type));
+ const label = document.createElement('span');
+ label.className = 'ev-attach-name';
+ label.textContent = a.filename;
+ label.title = a.filename;
+ item.appendChild(label);
+ const size = document.createElement('span');
+ size.className = 'ev-attach-size';
+ size.textContent = formatFileSize(a.size_bytes);
+ item.appendChild(size);
+ item.addEventListener('click', async () => {
+ try {
+ await api.download(`/local/attachments/${a.id}`, a.filename);
+ } catch (err) {
+ showToast((err && err.message) || '', true);
+ }
+ });
+ list.appendChild(item);
+ }
+}
+
function buildRruleFromUI() {
const sel = document.getElementById('ev-recurrence').value;
if (!sel) return null;
@@ -2305,9 +2606,11 @@ function bindEventModal() {
document.getElementById('ev-calendar').addEventListener('change', () => {
updatePrivateRow();
updateRemindersRow();
+ updateAttachmentsRow();
});
document.getElementById('ev-reminder-add').addEventListener('click', addReminderRow);
+ document.getElementById('ev-attachment-add').addEventListener('click', triggerAttachmentPicker);
// Custom calendar dropdown toggle
document.getElementById('ev-cal-trigger').addEventListener('click', e => {
@@ -2476,6 +2779,9 @@ function bindEventModal() {
await api.put(`/local/events/${encodeURIComponent(ev.id)}`,
{ title, start, end, allDay, location: loc, description: desc, color: color || null, rrule: rrule || '', private: isPrivate, reminders: state.eventReminders }
);
+ // Staged adds/removals are applied only now, so cancelling the modal
+ // really does change nothing.
+ await flushAttachmentChanges(ev.id);
} else if (ev.source === 'ical') {
showToast(t('event_readonly'), true);
return;
@@ -2509,12 +2815,15 @@ function bindEventModal() {
showToast(t('event_created'));
} else if (isLocal) {
const calId = parseInt(calVal.replace('local-', ''));
- await api.post('/local/events', {
+ // A new event has no uid until the server answers, so files picked
+ // beforehand were only staged — upload them now against the real uid.
+ const created = await api.post('/local/events', {
calendar_id: calId, title, start, end, allDay,
location: loc, description: desc, color: color || null,
rrule: rrule || null, private: isPrivate, reminders: state.eventReminders,
});
showToast(t('event_created'));
+ if (created && created.id) await flushAttachmentChanges(created.id);
} else if (isHA) {
const haCalId = parseInt(calVal.replace('homeassistant-', ''));
await api.post('/homeassistant/events', {
diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js
index d00b716..2415223 100644
--- a/frontend/js/i18n.js
+++ b/frontend/js/i18n.js
@@ -172,6 +172,20 @@ const translations = {
event_private: 'Privat',
reminders: 'Benachrichtigungen',
reminder_add: 'Benachrichtigung hinzufügen',
+ // Attachments
+ attachments: 'Anhänge',
+ attachment_add: 'Datei hinzufügen',
+ attachment_hint: 'Max. 10 MB pro Datei, 10 Dateien. PDF, Bilder, Text.',
+ attachment_none: 'Keine Anhänge',
+ attachment_uploading: 'Wird hochgeladen …',
+ attachment_deleted: 'Anhang gelöscht',
+ attachment_delete_confirm: "Anhang „{name}“ löschen?",
+ attachment_too_large: 'Datei zu groß (max. 10 MB)',
+ attachment_limit_reached: 'Maximal 10 Anhänge pro Termin',
+ attachment_type_not_allowed: 'Dateityp nicht erlaubt (PDF, Bild oder Textdatei)',
+ attachment_upload_failed: 'Hochladen fehlgeschlagen: {error}',
+ attachment_upload_partial: 'Termin gespeichert, aber {failed} von {total} Dateien konnten nicht hochgeladen werden',
+ attachment_save_first: 'Anhänge sind nur bei lokalen Kalendern möglich',
reminder_at_start: 'Zur Startzeit',
reminder_minutes: '{n} Minuten vorher',
reminder_hour_one: '1 Stunde vorher',
@@ -985,6 +999,20 @@ const translations = {
event_private: 'Private',
reminders: 'Reminders',
reminder_add: 'Add reminder',
+ // Attachments
+ attachments: 'Attachments',
+ attachment_add: 'Add file',
+ attachment_hint: 'Max. 10 MB per file, 10 files. PDF, images, text.',
+ attachment_none: 'No attachments',
+ attachment_uploading: 'Uploading …',
+ attachment_deleted: 'Attachment deleted',
+ attachment_delete_confirm: "Delete attachment “{name}”?",
+ attachment_too_large: 'File too large (max. 10 MB)',
+ attachment_limit_reached: 'At most 10 attachments per event',
+ attachment_type_not_allowed: 'File type not allowed (PDF, image or text file)',
+ attachment_upload_failed: 'Upload failed: {error}',
+ attachment_upload_partial: 'Event saved, but {failed} of {total} files could not be uploaded',
+ attachment_save_first: 'Attachments are only available on local calendars',
reminder_at_start: 'At start time',
reminder_minutes: '{n} minutes before',
reminder_hour_one: '1 hour before',