Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd8aba74b6 | ||
|
|
e2a7edf421 |
@@ -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/)
|
||||
|
||||
@@ -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}"; '
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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
@@ -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', {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user