feat(anhaenge): Anhaenge im Web-Editor und im Detail-Popup

Im Editor werden Dateien gestaget und erst beim Speichern angewendet --
sonst haette ein neuer Termin keine UID, an die der Anhang gehen koennte,
und "Abbrechen" wuerde nicht mehr bedeuten, dass nichts passiert ist.
Schlaegt danach ein Upload fehl, sagt der Toast das, statt Erfolg
vorzutaeuschen.

Das Popup laedt die Liste erst beim Oeffnen, weil das Event-JSON nur die
Anzahl traegt. Thumbnails gehen ueber authentifiziertes fetch auf eine
Blob-URL, weil ein img-Tag den Token nicht mitschicken kann -- die URL
wird nach dem Dekodieren freigegeben, anders als an den beiden
bestehenden Stellen, die sie liegen lassen.

Die fest verdrahtete Popup-Hoehe von 200 px wird jetzt gemessen; mit einer
Anhangliste stimmte die Annahme nicht mehr.

api.js haengt den HTTP-Status an geworfene Fehler, damit die Oberflaeche
409/413/400 uebersetzen kann statt das deutsche Backend-Detail zu zeigen.

Der ASCII-Rueckfallname im Content-Disposition war keiner: isalnum() ist
fuer Umlaute wahr. Faellt beim Herunterladen von "Rechnung Zahnaerztin.pdf"
auf, jetzt mit Test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-14 10:27:11 +02:00
co-authored by Claude Opus 5
parent a024fa54b9
commit e2a7edf421
7 changed files with 402 additions and 7 deletions
+4 -1
View File
@@ -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}"; '
+14
View File
@@ -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
+21
View File
@@ -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; }
+10
View File
@@ -397,6 +397,12 @@
<div id="ev-reminders-list" class="ev-reminders-list"></div>
<button type="button" class="btn btn-ghost btn-sm" id="ev-reminder-add" data-i18n="reminder_add">Benachrichtigung hinzufügen</button>
</div>
<div class="form-group" id="ev-attachments-group" style="display:none">
<label data-i18n="attachments">Anhänge</label>
<div id="ev-attachments-list" class="ev-attach-list"></div>
<button type="button" class="btn btn-ghost btn-sm" id="ev-attachment-add" data-i18n="attachment_add">Datei hinzufügen</button>
<div class="form-hint" data-i18n="attachment_hint">Max. 10 MB pro Datei, 10 Dateien. PDF, Bilder, Text.</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-danger hidden" id="ev-delete">Löschen</button>
@@ -554,6 +560,10 @@
<svg class="popup-row-icon" viewBox="0 0 24 24"><path d="M12 12a5 5 0 100-10 5 5 0 000 10zm0 2c-5.33 0-8 2.67-8 6v2h16v-2c0-3.33-2.67-6-8-6z"/></svg>
<span id="popup-creator"></span>
</div>
<div class="popup-row popup-row-attach" id="popup-row-attachments" style="display:none">
<svg class="popup-row-icon" viewBox="0 0 24 24"><path d="M16.5 6v11.5a4 4 0 11-8 0V5a2.5 2.5 0 115 0v10.5a1 1 0 11-2 0V6H10v9.5a2.5 2.5 0 105 0V5a4 4 0 10-8 0v12.5a5.5 5.5 0 1011 0V6h-1.5z"/></svg>
<div id="popup-attachments" class="popup-attach-list"></div>
</div>
</div>
<div id="popup-copy-menu" class="popup-copy-menu hidden"></div>
</div>
+13 -3
View File
@@ -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';
+312 -3
View File
@@ -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;
}
// <img src="/api/…"> 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', {
+28
View File
@@ -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',