Compare commits
2
Commits
a024fa54b9
...
cd8aba74b6
| 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
|
- Create, edit, and delete events with title, description, location, and time
|
||||||
- All-day events
|
- All-day events
|
||||||
- Per-event color overrides
|
- 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)
|
- iCal event overrides (hide or edit individual events from subscriptions)
|
||||||
- Dim past events option
|
- 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 |
|
| `SECRET_KEY` | *(auto-generated)* | JWT signing key — use a strong random value in production |
|
||||||
| `PORT` | `8080` | Server port |
|
| `PORT` | `8080` | Server port |
|
||||||
| `HOST` | `0.0.0.0` | Bind address |
|
| `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_ID` | — | Google OAuth2 Client ID *(optional)* |
|
||||||
| `GOOGLE_CLIENT_SECRET` | — | Google OAuth2 Client Secret *(optional)* |
|
| `GOOGLE_CLIENT_SECRET` | — | Google OAuth2 Client Secret *(optional)* |
|
||||||
| `GOOGLE_REDIRECT_URI` | — | e.g. `https://yourdomain.com/api/google/callback` *(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)* |
|
| `OIDC_PROVIDERS` | — | Comma-separated provider keys, e.g. `authentik` *(optional)* |
|
||||||
|
|
||||||
Per provider `<KEY>` listed in `OIDC_PROVIDERS` (variable names are uppercased):
|
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`
|
- iOS: `com.scarriffleservices.calendarr.ios:/oauth2redirect`
|
||||||
- Android: `com.scarriffle.calendarr:/oauth2redirect`
|
- Android: `com.scarriffle.calendarr:/oauth2redirect`
|
||||||
|
|
||||||
The mobile apps are separate repositories (`Calendarr-IOS`, `Calendarr-Android`)
|
The mobile apps live in this repository under `ios/` and `android/`, and
|
||||||
and authenticate as **public** clients via AppAuth — PKCE, no client secret.
|
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
|
Set `OIDC_<KEY>_MOBILE_CLIENT_ID` to the client id they use; without it the apps
|
||||||
show no SSO button.
|
show no SSO button.
|
||||||
|
|
||||||
@@ -194,6 +199,59 @@ same as 2FA users.
|
|||||||
**Rollback:** remove `OIDC_PROVIDERS` and restart. Accounts created via SSO
|
**Rollback:** remove `OIDC_PROVIDERS` and restart. Accounts created via SSO
|
||||||
remain and must be removed (or given a password) by an admin.
|
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
|
### Google Calendar Setup
|
||||||
|
|
||||||
1. Create a project in [Google Cloud Console](https://console.cloud.google.com/)
|
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.
|
# Row without bytes: the sweep will reconcile it on its next run.
|
||||||
raise HTTPException(404, "Attachment not found")
|
raise HTTPException(404, "Attachment not found")
|
||||||
# RFC 6266: an ASCII fallback plus the real UTF-8 name, so umlauts survive.
|
# 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(
|
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"
|
).strip() or "anhang"
|
||||||
disposition = (
|
disposition = (
|
||||||
f'attachment; filename="{ascii_name}"; '
|
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 r.status_code in (201, 204), r.text
|
||||||
assert _row_count() == 1
|
assert _row_count() == 1
|
||||||
assert len(_attach_props(client.get(dav_path).text)) == 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 { display: inline-flex; align-items: center; gap: 6px; flex-basis: 100%; }
|
||||||
.ev-reminder-custom input[type="number"] { width: 64px; }
|
.ev-reminder-custom input[type="number"] { width: 64px; }
|
||||||
.ev-reminder-before { color: var(--text-3); font-size: 12px; }
|
.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; }
|
.form-hint { font-size: 12px; color: var(--text-3); margin-bottom: 6px; }
|
||||||
.reminders-disabled .ev-reminders-list,
|
.reminders-disabled .ev-reminders-list,
|
||||||
.reminders-disabled #ev-reminder-add { opacity: 0.5; }
|
.reminders-disabled #ev-reminder-add { opacity: 0.5; }
|
||||||
|
|||||||
@@ -397,6 +397,12 @@
|
|||||||
<div id="ev-reminders-list" class="ev-reminders-list"></div>
|
<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>
|
<button type="button" class="btn btn-ghost btn-sm" id="ev-reminder-add" data-i18n="reminder_add">Benachrichtigung hinzufügen</button>
|
||||||
</div>
|
</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>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-danger hidden" id="ev-delete">Löschen</button>
|
<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>
|
<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>
|
<span id="popup-creator"></span>
|
||||||
</div>
|
</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>
|
||||||
<div id="popup-copy-menu" class="popup-copy-menu hidden"></div>
|
<div id="popup-copy-menu" class="popup-copy-menu hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+13
-3
@@ -34,7 +34,11 @@ async function request(method, path, body = null, formEncoded = false) {
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ detail: t('unknown_error') }));
|
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;
|
if (res.status === 204) return null;
|
||||||
@@ -61,7 +65,9 @@ async function uploadRequest(path, formData) {
|
|||||||
const detail = (err && err.detail)
|
const detail = (err && err.detail)
|
||||||
? err.detail
|
? err.detail
|
||||||
: (res.status === 413 ? t('upload_too_large') : `HTTP ${res.status} ${res.statusText || ''}`.trim());
|
: (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;
|
if (res.status === 204) return null;
|
||||||
return res.json();
|
return res.json();
|
||||||
@@ -81,7 +87,11 @@ async function downloadRequest(path, fallbackName) {
|
|||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ detail: t('unknown_error') }));
|
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.
|
// Derive filename from Content-Disposition if present.
|
||||||
let filename = fallbackName || 'calendar.ics';
|
let filename = fallbackName || 'calendar.ics';
|
||||||
|
|||||||
+312
-3
@@ -1625,9 +1625,12 @@ function showEventPopup(ev, anchor) {
|
|||||||
document.getElementById('popup-row-creator').style.display = 'none';
|
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 rect = anchor.getBoundingClientRect();
|
||||||
const pw = 320, ph = 200;
|
const pw = 320, ph = popup.offsetHeight || 200;
|
||||||
let left = rect.right + 8;
|
let left = rect.right + 8;
|
||||||
let top = rect.top;
|
let top = rect.top;
|
||||||
if (left + pw > window.innerWidth) left = rect.left - pw - 8;
|
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;
|
const defMin = state.settings && state.settings.default_reminder_minutes;
|
||||||
setEventReminders(defMin != null ? [defMin] : []);
|
setEventReminders(defMin != null ? [defMin] : []);
|
||||||
updateRemindersRow();
|
updateRemindersRow();
|
||||||
|
resetAttachmentState();
|
||||||
|
updateAttachmentsRow();
|
||||||
resetColorPicker('');
|
resetColorPicker('');
|
||||||
resetRecurrenceUI();
|
resetRecurrenceUI();
|
||||||
document.getElementById('ev-delete').classList.add('hidden');
|
document.getElementById('ev-delete').classList.add('hidden');
|
||||||
@@ -1996,6 +2001,8 @@ function openCopyEditModal(ev, targetCal) {
|
|||||||
updatePrivateRow(ev.private);
|
updatePrivateRow(ev.private);
|
||||||
setEventReminders(ev.reminders || []);
|
setEventReminders(ev.reminders || []);
|
||||||
updateRemindersRow();
|
updateRemindersRow();
|
||||||
|
resetAttachmentState();
|
||||||
|
updateAttachmentsRow();
|
||||||
|
|
||||||
resetColorPicker(ev.color || '');
|
resetColorPicker(ev.color || '');
|
||||||
resetRecurrenceUI();
|
resetRecurrenceUI();
|
||||||
@@ -2031,6 +2038,8 @@ function openEditEventModal(ev) {
|
|||||||
updatePrivateRow(ev.private);
|
updatePrivateRow(ev.private);
|
||||||
setEventReminders(ev.reminders || []);
|
setEventReminders(ev.reminders || []);
|
||||||
updateRemindersRow();
|
updateRemindersRow();
|
||||||
|
loadEventAttachments(ev);
|
||||||
|
updateAttachmentsRow();
|
||||||
resetColorPicker(ev.color || '');
|
resetColorPicker(ev.color || '');
|
||||||
|
|
||||||
// Recurrence
|
// Recurrence
|
||||||
@@ -2219,6 +2228,298 @@ function updateRemindersRow() {
|
|||||||
group.querySelectorAll('select, input, button').forEach(el => { el.disabled = disabled; });
|
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() {
|
function buildRruleFromUI() {
|
||||||
const sel = document.getElementById('ev-recurrence').value;
|
const sel = document.getElementById('ev-recurrence').value;
|
||||||
if (!sel) return null;
|
if (!sel) return null;
|
||||||
@@ -2305,9 +2606,11 @@ function bindEventModal() {
|
|||||||
document.getElementById('ev-calendar').addEventListener('change', () => {
|
document.getElementById('ev-calendar').addEventListener('change', () => {
|
||||||
updatePrivateRow();
|
updatePrivateRow();
|
||||||
updateRemindersRow();
|
updateRemindersRow();
|
||||||
|
updateAttachmentsRow();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('ev-reminder-add').addEventListener('click', addReminderRow);
|
document.getElementById('ev-reminder-add').addEventListener('click', addReminderRow);
|
||||||
|
document.getElementById('ev-attachment-add').addEventListener('click', triggerAttachmentPicker);
|
||||||
|
|
||||||
// Custom calendar dropdown toggle
|
// Custom calendar dropdown toggle
|
||||||
document.getElementById('ev-cal-trigger').addEventListener('click', e => {
|
document.getElementById('ev-cal-trigger').addEventListener('click', e => {
|
||||||
@@ -2476,6 +2779,9 @@ function bindEventModal() {
|
|||||||
await api.put(`/local/events/${encodeURIComponent(ev.id)}`,
|
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 }
|
{ 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') {
|
} else if (ev.source === 'ical') {
|
||||||
showToast(t('event_readonly'), true);
|
showToast(t('event_readonly'), true);
|
||||||
return;
|
return;
|
||||||
@@ -2509,12 +2815,15 @@ function bindEventModal() {
|
|||||||
showToast(t('event_created'));
|
showToast(t('event_created'));
|
||||||
} else if (isLocal) {
|
} else if (isLocal) {
|
||||||
const calId = parseInt(calVal.replace('local-', ''));
|
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,
|
calendar_id: calId, title, start, end, allDay,
|
||||||
location: loc, description: desc, color: color || null,
|
location: loc, description: desc, color: color || null,
|
||||||
rrule: rrule || null, private: isPrivate, reminders: state.eventReminders,
|
rrule: rrule || null, private: isPrivate, reminders: state.eventReminders,
|
||||||
});
|
});
|
||||||
showToast(t('event_created'));
|
showToast(t('event_created'));
|
||||||
|
if (created && created.id) await flushAttachmentChanges(created.id);
|
||||||
} else if (isHA) {
|
} else if (isHA) {
|
||||||
const haCalId = parseInt(calVal.replace('homeassistant-', ''));
|
const haCalId = parseInt(calVal.replace('homeassistant-', ''));
|
||||||
await api.post('/homeassistant/events', {
|
await api.post('/homeassistant/events', {
|
||||||
|
|||||||
@@ -172,6 +172,20 @@ const translations = {
|
|||||||
event_private: 'Privat',
|
event_private: 'Privat',
|
||||||
reminders: 'Benachrichtigungen',
|
reminders: 'Benachrichtigungen',
|
||||||
reminder_add: 'Benachrichtigung hinzufügen',
|
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_at_start: 'Zur Startzeit',
|
||||||
reminder_minutes: '{n} Minuten vorher',
|
reminder_minutes: '{n} Minuten vorher',
|
||||||
reminder_hour_one: '1 Stunde vorher',
|
reminder_hour_one: '1 Stunde vorher',
|
||||||
@@ -985,6 +999,20 @@ const translations = {
|
|||||||
event_private: 'Private',
|
event_private: 'Private',
|
||||||
reminders: 'Reminders',
|
reminders: 'Reminders',
|
||||||
reminder_add: 'Add reminder',
|
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_at_start: 'At start time',
|
||||||
reminder_minutes: '{n} minutes before',
|
reminder_minutes: '{n} minutes before',
|
||||||
reminder_hour_one: '1 hour before',
|
reminder_hour_one: '1 hour before',
|
||||||
|
|||||||
Reference in New Issue
Block a user