Files
Calendarr/frontend/js/api.js
T
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

135 lines
4.8 KiB
JavaScript

import { t } from './i18n.js';
const BASE = '/api';
// Paths where a 401 is a normal answer rather than an expired session, so the
// auto-logout reload must not fire (it would loop on a failed SSO handoff).
const NO_RELOAD_ON_401 = ['/auth/login', '/auth/oidc/complete', '/auth/oidc/exchange',
'/auth/oidc/link-pending'];
async function request(method, path, body = null, formEncoded = false) {
const token = localStorage.getItem('token');
const headers = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
let bodyStr = null;
if (body !== null) {
if (formEncoded) {
headers['Content-Type'] = 'application/x-www-form-urlencoded';
bodyStr = new URLSearchParams(body).toString();
} else {
headers['Content-Type'] = 'application/json';
bodyStr = JSON.stringify(body);
}
}
const res = await fetch(`${BASE}${path}`, { method, headers, body: bodyStr });
if (res.status === 401 && !NO_RELOAD_ON_401.some(p => path.startsWith(p))) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.reload();
return null;
}
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: t('unknown_error') }));
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;
return res.json();
}
async function uploadRequest(path, formData) {
const token = localStorage.getItem('token');
const headers = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${BASE}${path}`, { method: 'POST', headers, body: formData });
if (res.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.reload();
return null;
}
if (!res.ok) {
// Upload errors may be non-JSON (e.g. an nginx 413/502 HTML page); fall back
// to the HTTP status so the message is diagnostic, not "unknown error".
const err = await res.json().catch(() => null);
const detail = (err && err.detail)
? err.detail
: (res.status === 413 ? t('upload_too_large') : `HTTP ${res.status} ${res.statusText || ''}`.trim());
const e = new Error(detail);
e.status = res.status;
throw e;
}
if (res.status === 204) return null;
return res.json();
}
async function downloadRequest(path, fallbackName) {
const token = localStorage.getItem('token');
const headers = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${BASE}${path}`, { method: 'GET', headers });
if (res.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.reload();
return null;
}
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: t('unknown_error') }));
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';
const cd = res.headers.get('Content-Disposition') || '';
const m = cd.match(/filename="?([^"]+)"?/);
if (m) filename = m[1];
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
export const api = {
get: (path) => request('GET', path),
post: (path, body) => request('POST', path, body),
put: (path, body) => request('PUT', path, body),
delete: (path) => request('DELETE', path),
upload: (path, form) => uploadRequest(path, form),
download: (path, name) => downloadRequest(path, name),
login: (username, password, totp_code = null, remember_me = false) =>
request('POST', '/auth/login', { username, password, totp_code, remember_me }),
setupRequired: () => request('GET', '/auth/setup-required'),
setup: (data) => request('POST', '/auth/setup', data),
// Single sign-on. oidcComplete() trades the one-time HttpOnly handoff cookie
// set by the OIDC callback for the access token.
oidcProviders: () => request('GET', '/auth/oidc/providers'),
oidcComplete: () => request('POST', '/auth/oidc/complete', {}),
oidcIdentities: () => request('GET', '/auth/oidc/identities'),
oidcLinkPending: () => request('POST', '/auth/oidc/link-pending', {}),
oidcUnlink: (id) => request('DELETE', `/auth/oidc/identities/${id}`),
};