Compare commits

..
2 Commits
Author SHA1 Message Date
ScarriffleandClaude Opus 5 cd8aba74b6 docs: Anhaenge, Hintergrund-Jobs und neue Variablen im README
Dokumentiert auch den ehrlichen Haken der ATTACH-Links: sie muessen ohne
Login funktionieren, weil externe Kalender-Clients keinen mitschicken
koennen, und sind damit unbefristet gueltige Capability-URLs. Mit dem
Abschalter, falls das fuer eine Installation nicht tragbar ist.

Nebenbei die seit dem Monorepo-Merge falsche Aussage korrigiert, die
mobilen Apps laegen in eigenen Repos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:28:08 +02:00
ScarriffleandClaude Opus 5 e2a7edf421 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>
2026-09-14 10:27:11 +02:00
8 changed files with 464 additions and 11 deletions
+62 -4
View File
@@ -24,6 +24,7 @@ A clean, self-hosted calendar application with support for CalDAV, Google Calend
- Create, edit, and delete events with title, description, location, and time
- All-day events
- Per-event color overrides
- **Attachments** — PDFs, images and text files on local events (10 MB each, 10 per event)
- iCal event overrides (hide or edit individual events from subscriptions)
- Dim past events option
@@ -102,11 +103,15 @@ Configuration is done via environment variables. The `install.sh` script generat
| `SECRET_KEY` | *(auto-generated)* | JWT signing key — use a strong random value in production |
| `PORT` | `8080` | Server port |
| `HOST` | `0.0.0.0` | Bind address |
| `DATA_DIR` | `./data` | Directory for SQLite database and avatars |
| `DATA_DIR` | `./data` | Directory for the SQLite database, avatars, branding and attachments |
| `GOOGLE_CLIENT_ID` | — | Google OAuth2 Client ID *(optional)* |
| `GOOGLE_CLIENT_SECRET` | — | Google OAuth2 Client Secret *(optional)* |
| `GOOGLE_REDIRECT_URI` | — | e.g. `https://yourdomain.com/api/google/callback` *(optional)* |
| `PUBLIC_BASE_URL` | *(derived)* | Public origin behind a reverse proxy — **required for SSO** |
| `PUBLIC_BASE_URL` | *(derived)* | Public origin behind a reverse proxy — **required for SSO** and for attachment links in CalDAV |
| `ATTACHMENT_PUBLIC_LINKS` | `1` | `0` disables the unauthenticated attachment URLs (see below) |
| `SCHEDULER_ENABLED` | `1` | `0` disables all background jobs |
| `ATTACHMENT_SWEEP_HOURS` | `24` | How often orphaned attachments are cleaned up |
| `ICAL_REFRESH_MINUTES` | `15` | How often iCal subscriptions are checked in the background |
| `OIDC_PROVIDERS` | — | Comma-separated provider keys, e.g. `authentik` *(optional)* |
Per provider `<KEY>` listed in `OIDC_PROVIDERS` (variable names are uppercased):
@@ -138,8 +143,8 @@ Adds an SSO button to the login screen **alongside** password login. With
- iOS: `com.scarriffleservices.calendarr.ios:/oauth2redirect`
- Android: `com.scarriffle.calendarr:/oauth2redirect`
The mobile apps are separate repositories (`Calendarr-IOS`, `Calendarr-Android`)
and authenticate as **public** clients via AppAuth — PKCE, no client secret.
The mobile apps live in this repository under `ios/` and `android/`, and
authenticate as **public** clients via AppAuth — PKCE, no client secret.
Set `OIDC_<KEY>_MOBILE_CLIENT_ID` to the client id they use; without it the apps
show no SSO button.
@@ -194,6 +199,59 @@ same as 2FA users.
**Rollback:** remove `OIDC_PROVIDERS` and restart. Accounts created via SSO
remain and must be removed (or given a password) by an admin.
### Attachments
Events in **local** calendars can carry files: PDFs, images (PNG/JPEG/WebP/GIF)
and text files (TXT/Markdown/CSV), up to **10 MB per file and 10 per event**.
Events from CalDAV, Google, Home Assistant or iCal subscriptions cannot — they
are fetched live from their own server and have no record here to attach to.
Files are stored under `DATA_DIR/attachments`; **back that up together with the
database**, or a restored database will point at files that no longer exist.
Who may attach or remove a file is the same rule as who may edit the event:
the calendar's owner, group members, and anyone with a read-write share. A
read-only share can view and download. Private events of other users expose
neither their attachments nor their number.
**Attachments in external calendar clients.** The generated ICS carries an
`ATTACH` line per file, so Apple Calendar, Thunderbird and DAVx5 show the
attachment and can download it. Those clients cannot send a login, so the URL
has to work without one: each attachment gets its own unguessable 256-bit
token, and anyone who has that URL can fetch the file.
Weigh that honestly. The link only ever reaches clients that were already
allowed to read the calendar, and it dies when the attachment is deleted — but
it is long-lived, it ends up in plain text in the client's local calendar
store, and it would appear in a reverse-proxy access log. If that is not
acceptable for your deployment, set `ATTACHMENT_PUBLIC_LINKS=0`: no `ATTACH`
line is written and the public URL stops answering. Attachments then work in
the web UI and the mobile apps only.
`PUBLIC_BASE_URL` must be set for the links to be correct behind a reverse
proxy; without a usable base URL the `ATTACH` line is omitted entirely.
### Background jobs
The server runs two periodic jobs in-process:
| Job | Interval | What it does |
|---|---|---|
| `attachments.sweep` | 24 h | Removes attachment records whose event is gone, and files with no record |
| `ical.refresh` | 15 min | Re-fetches iCal subscriptions that are due |
Attachments are normally cleaned up the moment an event, calendar or user is
deleted; the sweep is the safety net for what gets past that — an external
CalDAV client deleting an event, or a request that died mid-upload. It leaves
files younger than an hour alone, so it cannot race an upload in progress.
The iCal job only drives the same refresh the app already did on demand, so
subscription intervals are unchanged. What is new is that subscriptions stay
current while nobody has the app open.
Both jobs assume a single server process, which is how `main.py` starts
uvicorn. Running multiple workers would run every job once per worker.
### Google Calendar Setup
1. Create a project in [Google Cloud Console](https://console.cloud.google.com/)
+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',